Compare commits
5 Commits
76b47006fe
...
0fa2c0c811
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fa2c0c811 | |||
| 6e485cc991 | |||
| bcfac52112 | |||
| 31aeee6d60 | |||
| a62007083d |
300
DEVELOPMENT.md
300
DEVELOPMENT.md
@@ -20,16 +20,16 @@
|
|||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
server/
|
server/
|
||||||
app.ts Bun HTTP 路由入口(路由分发 + API 汇聚、StaticAssets 接口定义)
|
|
||||||
bootstrap.ts 后端统一启动引导(loadConfig → store → engine → startServer → shutdown)
|
bootstrap.ts 后端统一启动引导(loadConfig → store → engine → startServer → shutdown)
|
||||||
config.ts CLI 参数解析(仅提取配置文件路径)
|
config.ts CLI 参数解析(仅提取配置文件路径)
|
||||||
dev.ts 开发模式启动入口
|
dev.ts 开发模式启动入口(mode: "development",HMR 自动注入)
|
||||||
server.ts HTTP server 启动工厂(接收 StartServerOptions)
|
main.ts 生产模式启动入口(mode: "production",安全头启用)
|
||||||
|
server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由 + HTML import)
|
||||||
helpers.ts 共享响应格式化工具(见下方函数清单)
|
helpers.ts 共享响应格式化工具(见下方函数清单)
|
||||||
middleware.ts API 参数校验中间件(guardGetHead、validateTargetId、validateTimeRange、validatePagination)
|
middleware.ts API 参数校验中间件(validateTargetId、validateTimeRange、validatePagination)
|
||||||
static.ts 静态资源服务与 SPA fallback
|
routes/ API 路由 handler(按端点拆分)
|
||||||
routes/ API 路由 handler(按端点拆分,签名因端点而异)
|
|
||||||
health.ts GET /health(无 store 参数)
|
health.ts GET /health(无 store 参数)
|
||||||
|
meta.ts GET /api/meta
|
||||||
summary.ts GET /api/summary
|
summary.ts GET /api/summary
|
||||||
targets.ts GET /api/targets
|
targets.ts GET /api/targets
|
||||||
history.ts GET /api/targets/:id/history
|
history.ts GET /api/targets/:id/history
|
||||||
@@ -61,22 +61,24 @@ src/
|
|||||||
command/ Command Checker(自包含模块,含 types/schema/execute/expect/validate/text)
|
command/ Command Checker(自包含模块,含 types/schema/execute/expect/validate/text)
|
||||||
shared/
|
shared/
|
||||||
api.ts 前后端共享 TypeScript 类型
|
api.ts 前后端共享 TypeScript 类型
|
||||||
web/ Vite + React 前端 Dashboard
|
web/ React 前端 Dashboard(通过 Bun HTML import 集成)
|
||||||
app.tsx 根组件(编排全局状态与布局)
|
app.tsx 根组件(编排全局状态与布局)
|
||||||
main.tsx 入口(QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools)
|
main.tsx 入口(QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools)
|
||||||
styles.css 全局样式与自定义 CSS 变量
|
styles.css 全局样式与自定义 CSS 变量
|
||||||
components/ UI 组件(见下方组件清单)
|
components/ UI 组件(见下方组件清单)
|
||||||
constants/ 常量与纯函数
|
constants/ 常量与纯函数
|
||||||
target-type-display.ts 类型名称映射
|
history-table-columns.tsx 历史记录表格列定义
|
||||||
target-table-columns.tsx 表格列定义
|
target-table-columns.tsx 目标表格列定义工厂
|
||||||
target-table-filters.ts 表格筛选器
|
target-table-filters.ts 表格筛选器
|
||||||
target-table-sorters.ts 表格排序器
|
target-table-sorters.ts 表格排序器
|
||||||
color-threshold.ts 可用率颜色阈值函数
|
color-threshold.ts 可用率颜色阈值函数
|
||||||
hooks/ TanStack Query 数据层
|
hooks/ TanStack Query 数据层
|
||||||
useTargetDetail.ts 集成轮询/条件查询的组合 hook
|
use-queries.ts 全局面板查询 hook(summary/targets/meta)
|
||||||
|
use-target-detail.ts 目标详情 Drawer 状态与条件查询 hook
|
||||||
utils/ 前端工具函数
|
utils/ 前端工具函数
|
||||||
time.ts 时间处理(subtractHours)
|
time.ts 时间处理(subtractHours)
|
||||||
scripts/ 开发、构建、schema 生成和 smoke test 脚本
|
stats.ts 趋势统计计算(computeTrendStats)
|
||||||
|
scripts/ 构建、schema 生成和清理脚本
|
||||||
tests/ Bun test 测试(结构镜像 src 目录)
|
tests/ Bun test 测试(结构镜像 src 目录)
|
||||||
openspec/ OpenSpec 变更与规格文档
|
openspec/ OpenSpec 变更与规格文档
|
||||||
probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动补全和校验)
|
probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动补全和校验)
|
||||||
@@ -96,12 +98,12 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动
|
|||||||
|
|
||||||
```
|
```
|
||||||
启动流程:
|
启动流程:
|
||||||
dev.ts / build entry → readRuntimeConfig(cli args, 仅提取 configPath)
|
dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath)
|
||||||
→ bootstrap({ configPath, mode, staticAssets? })
|
→ bootstrap({ configPath, mode })
|
||||||
→ loadConfig(yaml) → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets }
|
→ loadConfig(yaml) → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets }
|
||||||
→ ProbeStore(db) → store.syncTargets(targets)
|
→ ProbeStore(db) → store.syncTargets(targets)
|
||||||
→ ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start()
|
→ ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start()
|
||||||
→ startServer({ config, mode, store, staticAssets? })
|
→ startServer({ config, mode, store })
|
||||||
→ 注册 SIGINT/SIGTERM shutdown(engine.stop + store.close)
|
→ 注册 SIGINT/SIGTERM shutdown(engine.stop + store.close)
|
||||||
|
|
||||||
运行时:
|
运行时:
|
||||||
@@ -111,8 +113,9 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动
|
|||||||
数据清理: 定时 prune(retentionMs),每小时执行一次
|
数据清理: 定时 prune(retentionMs),每小时执行一次
|
||||||
|
|
||||||
HTTP 请求:
|
HTTP 请求:
|
||||||
Request → app.ts(路由分发) → routes/*.ts(handler)
|
Request → Bun.serve routes 声明式匹配 → routes/*.ts(handler)
|
||||||
→ middleware.ts(参数校验) → helpers.ts(响应格式化) → Response
|
→ middleware.ts(参数校验) → helpers.ts(响应格式化) → Response
|
||||||
|
前端: "/*": homepage (HTML import) → SPA fallback + HMR(开发模式)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2 库使用优先级
|
### 1.2 库使用优先级
|
||||||
@@ -131,50 +134,63 @@ HTTP 请求:
|
|||||||
|
|
||||||
### 1.3 API 路由开发
|
### 1.3 API 路由开发
|
||||||
|
|
||||||
路由文件位于 `src/server/routes/`,每个端点一个文件。handler 函数签名因端点而异:
|
路由文件位于 `src/server/routes/`,每个端点一个文件。路由通过 `server.ts` 的 `Bun.serve({ routes })` 声明式注册,使用 per-method handler 对象:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 无 store 的路由(健康检查不依赖数据库)
|
// server.ts 中的路由注册
|
||||||
export function handleHealth(method: string, mode: RuntimeMode): Response;
|
routes: {
|
||||||
|
"/*": homepage, // HTML import,SPA fallback
|
||||||
|
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
||||||
|
"/api/meta": { GET: () => handleMeta(mode) },
|
||||||
|
"/api/summary": { GET: () => handleSummary(store, mode) },
|
||||||
|
"/api/targets": { GET: () => handleTargets(store, mode) },
|
||||||
|
"/api/targets/:id/history": { GET: (req) => handleHistory(req.params.id, new URL(req.url), store, mode) },
|
||||||
|
"/api/targets/:id/trend": { GET: (req) => handleTrend(req.params.id, new URL(req.url), store, mode) },
|
||||||
|
"/health": { GET: () => handleHealth(mode) },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Handler 函数签名因端点而异:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 无 store 的路由
|
||||||
|
export function handleHealth(mode: RuntimeMode): Response;
|
||||||
|
export function handleMeta(mode: RuntimeMode): Response;
|
||||||
|
|
||||||
// 仅有 store 的路由
|
// 仅有 store 的路由
|
||||||
export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response;
|
export function handleSummary(store: ProbeStore, mode: RuntimeMode): Response;
|
||||||
export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response;
|
export function handleTargets(store: ProbeStore, mode: RuntimeMode): Response;
|
||||||
|
|
||||||
// 带 target ID 和查询参数的路由
|
// 带 target ID 和查询参数的路由
|
||||||
export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response;
|
export function handleHistory(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response;
|
||||||
export function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response;
|
export function handleTrend(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response;
|
||||||
```
|
```
|
||||||
|
|
||||||
**请求处理流程**:
|
**请求处理流程**:
|
||||||
|
|
||||||
1. `app.ts` 的 `createFetchHandler` 作为总入口,根据 URL pattern 匹配路由
|
1. `Bun.serve` 的 `routes` 对象按路径 + HTTP 方法匹配请求
|
||||||
2. `/health` 路由独立处理,不经过 `guardGetHead`(使用 `helpers.ts` 的 `allowsGetHead` 自行校验方法)
|
2. 未匹配方法的请求落入 `/api/*` 通配符(返回 404)
|
||||||
3. `/api/*` 路由统一经过 `guardGetHead` 做方法检查(仅允许 GET/HEAD),返回 `null` 表示通过
|
3. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination` 做参数校验,`pageSize` 最大值为 `200`
|
||||||
4. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId`、`validateTimeRange`、`validatePagination` 做参数校验,`pageSize` 最大值为 `200`
|
4. 校验函数返回 `Response` 实例表示校验失败(直接返回),返回数据对象表示通过
|
||||||
5. 校验函数返回 `Response` 实例表示校验失败(直接返回),返回数据对象表示通过
|
5. 业务逻辑通过 `store` 查询数据,用 `helpers.ts` 的 `jsonResponse`、`mapCheckResult`、`formatDuration` 等格式化输出
|
||||||
6. 业务逻辑通过 `store` 查询数据,用 `helpers.ts` 的 `jsonResponse`、`mapCheckResult`、`formatDuration` 等格式化输出
|
|
||||||
|
|
||||||
**新增路由步骤**:
|
**新增路由步骤**:
|
||||||
|
|
||||||
1. 在 `src/server/routes/` 下创建 `<name>.ts`
|
1. 在 `src/server/routes/` 下创建 `<name>.ts`
|
||||||
2. 实现 handler 函数并 export
|
2. 实现 handler 函数并 export
|
||||||
3. 在 `app.ts` 的 `createFetchHandler` 中注册路径匹配和调用
|
3. 在 `server.ts` 的 `routes` 对象中注册路径和 method handler
|
||||||
4. 在 `tests/server/app.test.ts` 中添加对应测试
|
4. 在 `tests/server/app.test.ts` 中添加对应测试
|
||||||
|
|
||||||
### 1.4 共享工具
|
### 1.4 共享工具
|
||||||
|
|
||||||
- **`helpers.ts`**:跨路由共用的响应工具函数
|
- **`helpers.ts`**:跨路由共用的响应工具函数
|
||||||
- `allowsGetHead(method)` — 判断是否为 GET/HEAD 方法
|
|
||||||
- `createApiError(error, status)` — 构造 API 错误体
|
- `createApiError(error, status)` — 构造 API 错误体
|
||||||
- `createHeaders(mode, init)` — 创建响应 Headers(生产模式附加安全头)
|
- `createHeaders(mode, init)` — 创建响应 Headers(生产模式附加安全头)
|
||||||
- `createHealthResponse()` — 构造健康检查响应
|
- `createHealthResponse()` — 构造健康检查响应
|
||||||
- `formatDuration(ms)` — 毫秒转为可读时长字符串
|
- `formatDuration(ms)` — 毫秒转为可读时长字符串
|
||||||
- `jsonResponse(body, options)` — JSON 响应构造(自动处理 HEAD 空体)
|
- `jsonResponse(body, options)` — JSON 响应构造
|
||||||
- `mapCheckResult(row)` — 数据库行转 API CheckResult
|
- `mapCheckResult(row)` — 数据库行转 API CheckResult
|
||||||
- `methodNotAllowedResponse(allow, mode)` — 构造 405 响应
|
- **`middleware.ts`**:API 参数校验函数(`validateTargetId`、`validateTimeRange`、`validatePagination`,其中 `pageSize` 上限为 `200`)
|
||||||
- **`middleware.ts`**:API 参数校验函数(`guardGetHead`、`validateTargetId`、`validateTimeRange`、`validatePagination`,其中 `pageSize` 上限为 `200`)
|
|
||||||
- **`static.ts`**:生产模式下的静态资源服务与 SPA fallback
|
|
||||||
|
|
||||||
### 1.5 类型定义规范
|
### 1.5 类型定义规范
|
||||||
|
|
||||||
@@ -358,14 +374,11 @@ TcpChecker implements Checker
|
|||||||
| `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` |
|
| `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` |
|
||||||
| `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` |
|
| `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` |
|
||||||
|
|
||||||
注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新前端展示常量、配置示例、文档和测试。
|
注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新配置示例、文档和测试。
|
||||||
|
|
||||||
#### 1.7.7 步骤六:更新前端展示
|
#### 1.7.7 步骤六:确认前端类型展示
|
||||||
|
|
||||||
| 文件 | 修改内容 |
|
前端通过 `/api/meta` 获取 `checkerRegistry.supportedTypes` 并动态生成类型筛选器,类型列和详情标题直接显示 `target.type` 原始文本。新增 checker 注册后无需更新前端类型映射或筛选常量。
|
||||||
| ------------------------------------------- | ------------------------------------------------------------ |
|
|
||||||
| `src/web/constants/target-type-display.ts` | 在 `TARGET_TYPE_DISPLAY` 中添加 `"tcp": "TCP"` |
|
|
||||||
| `src/web/constants/target-table-filters.ts` | 在 `typeFilter.list` 中添加 `{ label: "TCP", value: "tcp" }` |
|
|
||||||
|
|
||||||
#### 1.7.8 步骤七:编写测试
|
#### 1.7.8 步骤七:编写测试
|
||||||
|
|
||||||
@@ -400,13 +413,11 @@ TcpChecker implements Checker
|
|||||||
□ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要)
|
□ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要)
|
||||||
□ src/server/checker/runner/tcp/index.ts — 模块入口(re-export)
|
□ src/server/checker/runner/tcp/index.ts — 模块入口(re-export)
|
||||||
□ src/server/checker/runner/index.ts — 注册(一行导入 + 一个数组元素)
|
□ src/server/checker/runner/index.ts — 注册(一行导入 + 一个数组元素)
|
||||||
□ src/web/constants/target-type-display.ts — 前端类型标签
|
|
||||||
□ src/web/constants/target-table-filters.ts — 前端类型筛选
|
|
||||||
□ tests/ — 契约 + 校验 + resolve + execute + 注册 测试
|
□ tests/ — 契约 + 校验 + resolve + execute + 注册 测试
|
||||||
□ probes.example.yaml — 配置示例
|
□ probes.example.yaml — 配置示例
|
||||||
□ bun run schema + bun run schema:check — Schema 导出同步
|
□ bun run schema + bun run schema:check — Schema 导出同步
|
||||||
□ bun run check — 全量质量检查通过
|
□ bun run check — 全量质量检查通过
|
||||||
□ bun run verify — 完整验证(含 build + smoke test)
|
□ bun run verify — 完整验证(check + build)
|
||||||
□ README.md — 用户文档
|
□ README.md — 用户文档
|
||||||
□ DEVELOPMENT.md — 项目结构目录树
|
□ DEVELOPMENT.md — 项目结构目录树
|
||||||
```
|
```
|
||||||
@@ -486,18 +497,20 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
|
|||||||
**Body 规则类型**(`runner/http/body.ts`):
|
**Body 规则类型**(`runner/http/body.ts`):
|
||||||
|
|
||||||
- `contains`:文本包含匹配
|
- `contains`:文本包含匹配
|
||||||
- `regex`:正则表达式匹配(注意:body 正则字段为 `regex`,不是 `match`)
|
- `regex`:正则表达式匹配(注意:body 正则字段为 `regex`,不是 `match`,启动期会拒绝嵌套量词等 ReDoS 风险模式)
|
||||||
- `json`:JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符)
|
- `json`:JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符)
|
||||||
- `css`:cheerio CSS 选择器 + 操作符比较
|
- `css`:cheerio CSS 选择器 + 操作符比较
|
||||||
- `xpath`:XPath 节点提取 + 操作符比较
|
- `xpath`:XPath 节点提取 + 操作符比较
|
||||||
|
|
||||||
**文本规则**(`runner/command/text.ts`):stdout/stderr 文本匹配,支持 `contains`、`match`(正则)、操作符比较
|
**文本规则**(`runner/command/text.ts`):stdout/stderr 文本匹配,支持 `contains`、`match`(正则)、操作符比较
|
||||||
|
|
||||||
**操作符**(`expect/operator.ts`):`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt`
|
**操作符**(`expect/operator.ts`):`equals`(深度比较,`es-toolkit/isEqual`)、`contains`、`match`(正则,启动期通过 `expect/redos.ts` 拒绝 ReDoS 风险模式)、`empty`(`isNil`+`isEmptyObject`)、`exists`、`gte`/`lte`/`gt`/`lt`
|
||||||
|
|
||||||
|
启动期语义校验会对 HTTP body `regex` 规则和所有 `match` operator 执行静态 ReDoS 检测,常见的嵌套量词模式如 `(a+)+`、`(\\d+)*x` 会被拒绝,避免运行期正则在大响应体上阻塞事件循环。
|
||||||
|
|
||||||
### 1.11 错误模式
|
### 1.11 错误模式
|
||||||
|
|
||||||
- **API 错误**:`{ error: "描述", status: <code> }`,状态码 400/404/405/503
|
- **API 错误**:`{ error: "描述", status: <code> }`,状态码 400/404/503
|
||||||
- **CheckFailure**:`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }`
|
- **CheckFailure**:`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }`
|
||||||
- **错误处理**:expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`,body 超限/解码/解析错误归属 `phase:"body"`
|
- **错误处理**:expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`,body 超限/解码/解析错误归属 `phase:"body"`
|
||||||
- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
|
- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
|
||||||
@@ -523,14 +536,14 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
|
|||||||
| 层面 | 技术 | 用途 |
|
| 层面 | 技术 | 用途 |
|
||||||
| ------ | --------------------------------------------------- | ---------------------------- |
|
| ------ | --------------------------------------------------- | ---------------------------- |
|
||||||
| 框架 | React 19 | UI 组件开发 |
|
| 框架 | React 19 | UI 组件开发 |
|
||||||
| 构建 | Vite 8 | 开发服务与生产构建 |
|
| 构建 | Bun HTML import(fullstack 模式) | 开发服务与生产构建 |
|
||||||
| 语言 | TypeScript 6 | 类型安全 |
|
| 语言 | TypeScript 6 | 类型安全 |
|
||||||
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
|
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
|
||||||
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 |
|
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 |
|
||||||
| 图表 | Recharts | 拨测趋势折线图与状态环状图 |
|
| 图表 | Recharts | 拨测趋势折线图与状态环状图 |
|
||||||
| 路由 | 无(单页面 Dashboard) | 仅需 Drawer/Tab 做页面内导航 |
|
| 路由 | 无(单页面 Dashboard) | 仅需 Drawer/Tab 做页面内导航 |
|
||||||
|
|
||||||
**不引入的依赖**:React Router(单页面场景不需要)、状态管理库(TanStack Query 即服务端状态层,组件内用 `useState` 足够)
|
**不引入的依赖**:React Router(单页面场景不需要)、状态管理库(TanStack Query 即服务端状态层,组件内用 `useState` 足够)、Vite(已由 Bun 原生 fullstack 替代)
|
||||||
|
|
||||||
### 2.2 组件树与数据流
|
### 2.2 组件树与数据流
|
||||||
|
|
||||||
@@ -544,23 +557,26 @@ main.tsx
|
|||||||
│ │ └── useSummary() ─── GET /api/summary(8s 轮询)
|
│ │ └── useSummary() ─── GET /api/summary(8s 轮询)
|
||||||
│ └── TargetBoard(目标列表)
|
│ └── TargetBoard(目标列表)
|
||||||
│ ├── useTargets() ─── GET /api/targets(8s 轮询)
|
│ ├── useTargets() ─── GET /api/targets(8s 轮询)
|
||||||
|
│ ├── useMeta() ────── GET /api/meta(应用生命周期内缓存)
|
||||||
│ └── TargetGroup[](按 group 字段分组)
|
│ └── TargetGroup[](按 group 字段分组)
|
||||||
│ └── PrimaryTable ← TARGET_TABLE_COLUMNS(列定义:排序/筛选/渲染)
|
│ └── PrimaryTable ← createTargetTableColumns(checkerTypes)
|
||||||
│ └── TargetDetailDrawer(目标详情抽屉)
|
│ └── TargetDetailDrawer(目标详情抽屉)
|
||||||
│ └── useTargetDetail() ── 按需发起 trend + history 查询
|
│ └── useTargetDetail() ── 按需发起 trend + history 查询
|
||||||
│ ├── Tab: 概览 → Statistic + TrendChart + StatusDonut + Descriptions
|
│ ├── OverviewTab → Statistic + TrendChart + StatusDonut + Descriptions
|
||||||
│ └── Tab: 记录 → PrimaryTable(分页历史记录)
|
│ └── HistoryTab → PrimaryTable(分页历史记录)
|
||||||
└── ReactQueryDevtools(开发工具,仅开发环境)
|
└── ReactQueryDevtools(开发工具,仅开发环境)
|
||||||
```
|
```
|
||||||
|
|
||||||
**数据层架构**:
|
**数据层架构**:
|
||||||
|
|
||||||
```
|
```
|
||||||
hooks/useTargetDetail.ts(唯一的数据层入口)
|
hooks/use-queries.ts(全局面板级查询)
|
||||||
├── queryKeys(结构化 query key,确保缓存粒度精确)
|
├── queryKeys(summary/targets/meta 结构化 query key)
|
||||||
├── useSummary() → /api/summary(8s 自动轮询)
|
├── useSummary() → /api/summary(8s 自动轮询)
|
||||||
├── useTargets() → /api/targets(8s 自动轮询)
|
├── useTargets() → /api/targets(8s 自动轮询)
|
||||||
└── useTargetDetail()(组合 hook,管理 Drawer 全部状态)
|
└── useMeta() → /api/meta(staleTime: Infinity)
|
||||||
|
|
||||||
|
hooks/use-target-detail.ts(Drawer 状态与详情级条件查询)
|
||||||
├── 内部复用 useTargets() 的缓存来查找 selectedTarget
|
├── 内部复用 useTargets() 的缓存来查找 selectedTarget
|
||||||
├── useQuery(/api/targets/:id/trend)(条件查询:enabled 仅当 Drawer 打开且时间范围有效)
|
├── useQuery(/api/targets/:id/trend)(条件查询:enabled 仅当 Drawer 打开且时间范围有效)
|
||||||
└── useQuery(/api/targets/:id/history)(条件查询:含分页)
|
└── useQuery(/api/targets/:id/history)(条件查询:含分页)
|
||||||
@@ -574,6 +590,7 @@ hooks/useTargetDetail.ts(唯一的数据层入口)
|
|||||||
const queryKeys = {
|
const queryKeys = {
|
||||||
summary: () => ["summary"] as const,
|
summary: () => ["summary"] as const,
|
||||||
targets: () => ["targets"] as const,
|
targets: () => ["targets"] as const,
|
||||||
|
meta: () => ["meta"] as const,
|
||||||
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
||||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||||
};
|
};
|
||||||
@@ -668,7 +685,9 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
|||||||
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) |
|
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) |
|
||||||
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 |
|
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 |
|
||||||
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable |
|
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable |
|
||||||
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(概览/记录 Tab) |
|
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉壳、时间选择和 Tab 切换 |
|
||||||
|
| `OverviewTab` | `components/OverviewTab.tsx` | 目标详情概览(统计/趋势/状态分布/信息) |
|
||||||
|
| `HistoryTab` | `components/HistoryTab.tsx` | 目标历史记录表格和分页 |
|
||||||
| `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) |
|
| `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) |
|
||||||
| `StatusDonut` | `components/StatusDonut.tsx` | Recharts 环状图(UP/DOWN 分布) |
|
| `StatusDonut` | `components/StatusDonut.tsx` | Recharts 环状图(UP/DOWN 分布) |
|
||||||
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
|
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
|
||||||
@@ -682,7 +701,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
|||||||
1. **确认数据需求**:是已有 API 数据还是需要新端点?
|
1. **确认数据需求**:是已有 API 数据还是需要新端点?
|
||||||
- 如有新端点,先在 `src/server/routes/` 添加,参考 [1.3 新增路由步骤](#13-api-路由开发)
|
- 如有新端点,先在 `src/server/routes/` 添加,参考 [1.3 新增路由步骤](#13-api-路由开发)
|
||||||
- 如有新字段,更新 `src/shared/api.ts` 类型定义
|
- 如有新字段,更新 `src/shared/api.ts` 类型定义
|
||||||
2. **实现 hooks**:在 `src/web/hooks/useTargetDetail.ts` 中新增 `useQuery`(写好 `queryKey` 和 `enabled` 条件)
|
2. **实现 hooks**:全局查询放在 `src/web/hooks/use-queries.ts`;目标详情条件查询放在 `src/web/hooks/use-target-detail.ts`(写好 `queryKey` 和 `enabled` 条件)
|
||||||
3. **编写组件**:在 `src/web/components/` 创建组件文件
|
3. **编写组件**:在 `src/web/components/` 创建组件文件
|
||||||
- 在 `TargetDetailDrawer.tsx` 中新增 `<Tabs.TabPanel>` 引用
|
- 在 `TargetDetailDrawer.tsx` 中新增 `<Tabs.TabPanel>` 引用
|
||||||
4. **编写常量**:如有列定义/排序器/筛选器,放在 `src/web/constants/`
|
4. **编写常量**:如有列定义/排序器/筛选器,放在 `src/web/constants/`
|
||||||
@@ -724,81 +743,47 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
|||||||
|
|
||||||
### 3.1 开发期运行
|
### 3.1 开发期运行
|
||||||
|
|
||||||
#### 同时启动前后端
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev probes.yaml
|
bun run dev probes.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
`scripts/dev.ts` 通过 `Bun.spawn` 同时启动两个子进程:
|
`bun --watch src/server/dev.ts` 启动单进程 fullstack 开发服务器:
|
||||||
|
|
||||||
```
|
- 后端 API + 前端 SPA 在同一端口(默认 3000)
|
||||||
bun run dev probes.yaml
|
- `development` 模式自动注入 HMR,前端修改即时热更新
|
||||||
├── bun run dev:server probes.yaml → Bun HTTP 后端(默认 3000 端口)
|
- `--watch` 监听后端文件变更自动重启
|
||||||
└── bun run dev:web → Vite 前端开发服务器(5173 端口)
|
- 访问 `http://127.0.0.1:3000` 即可使用完整应用
|
||||||
```
|
|
||||||
|
|
||||||
- 任一子进程退出会导致整体退出
|
|
||||||
- `SIGINT`/`SIGTERM` 信号会同时终止两个子进程
|
|
||||||
- `BACKEND_PORT` 环境变量可覆盖后端端口
|
|
||||||
|
|
||||||
#### 分别启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动后端(含 watch 模式自动重启)
|
|
||||||
bun run dev:server probes.yaml
|
|
||||||
|
|
||||||
# 另开终端启动前端
|
|
||||||
bun run dev:web
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 前后端集成方式
|
### 3.2 前后端集成方式
|
||||||
|
|
||||||
#### 开发期代理
|
#### 统一进程架构
|
||||||
|
|
||||||
Vite 配置了开发代理(`vite.config.ts`)和代码分割策略:
|
前后端通过 Bun 的 HTML import 机制集成为单进程应用:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 开发代理
|
// server.ts
|
||||||
server: {
|
import homepage from "../web/index.html";
|
||||||
proxy: {
|
|
||||||
"/api": {
|
const server = Bun.serve({
|
||||||
target: `http://127.0.0.1:${backendPort}`,
|
development: mode === "development" ? { hmr: true, console: true } : false,
|
||||||
changeOrigin: true,
|
routes: {
|
||||||
|
"/*": homepage, // SPA fallback(开发模式自动注入 HMR)
|
||||||
|
"/api/*": () => ..., // API 通配符(未匹配路由返回 404)
|
||||||
|
"/api/summary": { GET: () => handleSummary(store, mode) },
|
||||||
|
"/health": { GET: () => handleHealth(mode) },
|
||||||
|
// ...
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 生产代码分割(rolldownOptions.output.codeSplitting.groups)
|
|
||||||
// vendor-react: react/react-dom/scheduler
|
|
||||||
// vendor-tdesign: tdesign
|
|
||||||
// vendor-chart: recharts/d3-*
|
|
||||||
```
|
```
|
||||||
|
|
||||||
前端访问 `/api/*` 时,Vite 开发服务器自动转发到后端 `http://127.0.0.1:${backendPort}`,无需 CORS 配置。
|
- 开发模式(`development: { hmr: true, console: true }`):Bun 自动为 HTML import 注入 HMR client,前端修改无需手动刷新,并将浏览器 console 回显到终端
|
||||||
|
- 生产模式:HTML 及其引用的 JS/CSS 资源在 `bun build --compile` 时自动打包进可执行文件
|
||||||
|
|
||||||
前端开发地址为 `http://127.0.0.1:5173`(严格端口 `strictPort: true`)。
|
#### 路由优先级
|
||||||
|
|
||||||
后端在开发模式下不提供静态资源服务,访问 `http://127.0.0.1:3000` 会提示"请通过 Vite 前端地址访问"。
|
Bun routes 的匹配规则:具体路径 > 通配符。`/api/summary` 优先于 `/api/*`,`/health` 优先于 `/*`。
|
||||||
|
|
||||||
#### 生产期集成
|
未匹配 method 的请求(如 POST /api/summary)会落入 `/api/*` 通配符返回 404。
|
||||||
|
|
||||||
生产可执行文件是单体应用:前端静态资源嵌入 binary(通过 `StaticAssets` 接口:`files: Record<string, Blob>` + `indexHtml: Blob`),后端同时提供 API 和静态文件服务。
|
|
||||||
|
|
||||||
```
|
|
||||||
./dist/dial-server probes.yaml
|
|
||||||
|
|
||||||
启动后:
|
|
||||||
访问 http://127.0.0.1:3000/ → 返回前端 SPA(index.html)
|
|
||||||
访问 http://127.0.0.1:3000/api/* → 返回后端 API
|
|
||||||
访问 /assets/* → 返回带不可变缓存的静态资源
|
|
||||||
```
|
|
||||||
|
|
||||||
SPA fallback 逻辑(`src/server/static.ts`):
|
|
||||||
|
|
||||||
- `/` → index.html
|
|
||||||
- 匹配 `/assets/*` → 返回对应文件(未匹配则 404)
|
|
||||||
- 其他路径(如 `/dashboard`)→ fallback 到 index.html(SPA 路由)
|
|
||||||
|
|
||||||
### 3.3 构建打包
|
### 3.3 构建打包
|
||||||
|
|
||||||
@@ -808,33 +793,28 @@ SPA fallback 逻辑(`src/server/static.ts`):
|
|||||||
bun run build
|
bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 构建流程详解
|
#### 构建流程
|
||||||
|
|
||||||
`scripts/build.ts` 执行以下步骤:
|
`scripts/build.ts` 执行单步构建:
|
||||||
|
|
||||||
```
|
```
|
||||||
1. vite build
|
Bun.build({
|
||||||
├── 入口:src/web/index.html
|
compile: { outfile: "dist/dial-server" },
|
||||||
└── 输出:dist/web/(index.html + assets/)
|
entrypoints: ["src/server/main.ts"],
|
||||||
|
minify: true,
|
||||||
2. 生成 .build/static-assets.ts(临时文件)
|
sourcemap: "linked",
|
||||||
├── import Vite 产物为 Bun.file
|
})
|
||||||
└── 导出 staticAssets: StaticAssets 对象
|
|
||||||
|
|
||||||
3. 生成 .build/server-entry.ts(临时文件)
|
|
||||||
└── import bootstrap + staticAssets,调用 production bootstrap,作为 Bun.build 入口
|
|
||||||
|
|
||||||
4. Bun.build({ compile, minify, sourcemap: "linked" })
|
|
||||||
└── 输出:dist/dial-server(单文件可执行 binary)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- 入口为 `src/server/main.ts`(`mode: "production"`,启用安全头)
|
||||||
|
- HTML import 的前端资源自动打包进可执行文件(Bun 自动生成 manifest)
|
||||||
|
- 无需中间产物目录,一步生成最终 binary
|
||||||
|
|
||||||
#### 产物
|
#### 产物
|
||||||
|
|
||||||
| 产物 | 用途 |
|
| 产物 | 用途 |
|
||||||
| ------------------ | -------------------------- |
|
| ------------------ | ---------------------------------------- |
|
||||||
| `dist/dial-server` | 生产可执行文件 |
|
| `dist/dial-server` | 生产可执行文件(含前端资源,单文件部署) |
|
||||||
| `dist/web/` | Vite 构建产物(中间产物) |
|
|
||||||
| `.build/` | 临时生成文件(构建后清理) |
|
|
||||||
|
|
||||||
#### 构建参数
|
#### 构建参数
|
||||||
|
|
||||||
@@ -848,11 +828,17 @@ bun run build
|
|||||||
./dist/dial-server probes.yaml
|
./dist/dial-server probes.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
启动后:
|
||||||
|
|
||||||
|
- 访问 `http://127.0.0.1:3000/` → 返回前端 SPA
|
||||||
|
- 访问 `http://127.0.0.1:3000/api/*` → 返回后端 API
|
||||||
|
- 访问 `/dashboard` 等前端路由 → SPA fallback 到 index.html
|
||||||
|
|
||||||
#### 清理
|
#### 清理
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run clean
|
bun run clean
|
||||||
# 清理 dist/ 构建产物、.build/ 缓存和 *.bun-build 临时文件
|
# 清理 dist/ 构建产物和 *.bun-build 临时文件
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.4 开发工作流
|
### 3.4 开发工作流
|
||||||
@@ -860,8 +846,8 @@ bun run clean
|
|||||||
#### 日常开发循环
|
#### 日常开发循环
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev probes.yaml # 启动开发环境
|
bun run dev probes.yaml # 启动开发环境(单进程,含 HMR)
|
||||||
# 修改代码 → Vite HMR(前端)/ bun --watch(后端自动重启)
|
# 修改前端代码 → HMR 热更新 / 修改后端代码 → --watch 自动重启
|
||||||
bun run check # 提交前运行完整质量检查
|
bun run check # 提交前运行完整质量检查
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -869,43 +855,29 @@ bun run check # 提交前运行完整质量检查
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run verify
|
bun run verify
|
||||||
# = bun run check + bun run build + bun run test:smoke
|
# = bun run check + bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
`verify` 适合 CI 或正式提交前,会完整验证类型检查、lint、格式、单元测试、构建、smoke test。
|
`verify` 适合 CI 或正式提交前,会完整验证类型检查、lint、格式、单元测试和生产构建。
|
||||||
|
|
||||||
### 3.5 Smoke Test
|
### 3.5 Executable/E2E 验证
|
||||||
|
|
||||||
```bash
|
原 `scripts/smoke.ts` 覆盖过薄,已从当前工作流移除。后续如需验证 production executable 的 API、HTML import manifest、SPA fallback 和静态资源行为,应重新设计独立的 executable/E2E 测试。
|
||||||
bun run test:smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
`scripts/smoke.ts` 构建后验证流程:
|
|
||||||
|
|
||||||
1. 动态分配空闲端口
|
|
||||||
2. 用临时配置文件启动 `dist/dial-server`
|
|
||||||
3. 等待健康检查通过
|
|
||||||
4. 验证所有 API 端点返回正确数据
|
|
||||||
5. 验证静态资源服务(含 SPA fallback 和 404 处理)
|
|
||||||
6. 验证安全 headers
|
|
||||||
7. 测试结束清理临时目录和进程
|
|
||||||
|
|
||||||
### 3.6 脚本说明
|
### 3.6 脚本说明
|
||||||
|
|
||||||
| 脚本 | 文件 | 说明 |
|
| 脚本 | 文件 | 说明 |
|
||||||
| ---------------------- | ----------------------------------- | ------------------------------- |
|
| ---------------------- | ----------------------------------- | ----------------------------------- |
|
||||||
| `bun run dev` | `scripts/dev.ts` | 同时启动前后端开发服务 |
|
| `bun run dev` | `src/server/dev.ts` | 单进程 fullstack 开发服务(含 HMR) |
|
||||||
| `bun run build` | `scripts/build.ts` | Vite 构建 + Bun 编译可执行文件 |
|
| `bun run build` | `scripts/build.ts` | Bun 编译可执行文件(含前端资源) |
|
||||||
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` |
|
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` |
|
||||||
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 |
|
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 |
|
||||||
| `bun run test:smoke` | `scripts/smoke.ts` | 构建后的端到端验证 |
|
|
||||||
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
|
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
|
||||||
|
|
||||||
### 3.7 环境变量
|
### 3.7 环境变量
|
||||||
|
|
||||||
| 变量 | 用途 | 默认值 |
|
| 变量 | 用途 | 默认值 |
|
||||||
| --------------------------- | ---------------------------------------------------- | -------- |
|
| --------------------------- | ----------------------------------------------- | -------- |
|
||||||
| `PORT`/`BACKEND_PORT` | 后端监听端口(开发期 Vite 代理目标、生产期监听端口) | `3000` |
|
|
||||||
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 |
|
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 |
|
||||||
|
|
||||||
### 3.8 项目配置文件
|
### 3.8 项目配置文件
|
||||||
@@ -914,7 +886,6 @@ bun run test:smoke
|
|||||||
| ---------------------- | ---------------------------------------------- |
|
| ---------------------- | ---------------------------------------------- |
|
||||||
| `package.json` | 项目信息、脚本、依赖声明 |
|
| `package.json` | 项目信息、脚本、依赖声明 |
|
||||||
| `tsconfig.json` | TypeScript 配置(ESNext 模块、严格模式) |
|
| `tsconfig.json` | TypeScript 配置(ESNext 模块、严格模式) |
|
||||||
| `vite.config.ts` | Vite 开发代理与构建配置(含代码分割策略) |
|
|
||||||
| `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) |
|
| `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) |
|
||||||
| `commitlint.config.js` | commitlint 提交信息格式校验 |
|
| `commitlint.config.js` | commitlint 提交信息格式校验 |
|
||||||
| `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120`) |
|
| `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120`) |
|
||||||
@@ -932,14 +903,13 @@ bun run test:smoke
|
|||||||
### 3.10 目录约定
|
### 3.10 目录约定
|
||||||
|
|
||||||
| 目录 | 约定 |
|
| 目录 | 约定 |
|
||||||
| ------------- | -------------------------------------------- |
|
| ------------- | ---------------------------------------------------- |
|
||||||
| `src/server/` | 后端代码,不能 import `src/web/` |
|
| `src/server/` | 后端代码,不能 import `src/web/`(HTML import 除外) |
|
||||||
| `src/web/` | 前端代码,不能 import `src/server/` |
|
| `src/web/` | 前端代码,不能 import `src/server/` |
|
||||||
| `src/shared/` | 前后端共享类型,双向可引用 |
|
| `src/shared/` | 前后端共享类型,双向可引用 |
|
||||||
| `scripts/` | 独立运行脚本,可 import 项目源码 |
|
| `scripts/` | 独立运行脚本,可 import 项目源码 |
|
||||||
| `tests/` | 测试目录,结构镜像 src 目录 |
|
| `tests/` | 测试目录,结构镜像 src 目录 |
|
||||||
| `dist/` | 构建产物(gitignore) |
|
| `dist/` | 构建产物(gitignore) |
|
||||||
| `.build/` | 构建临时文件(gitignore) |
|
|
||||||
| `openspec/` | OpenSpec 变更管理与规格文档 |
|
| `openspec/` | OpenSpec 变更管理与规格文档 |
|
||||||
| `data/` | 默认数据目录(gitignore,运行期生成 SQLite) |
|
| `data/` | 默认数据目录(gitignore,运行期生成 SQLite) |
|
||||||
|
|
||||||
@@ -982,10 +952,10 @@ bun run check # 一键运行 schema:check + typecheck + lint + test
|
|||||||
### TypeScript 严格标志
|
### TypeScript 严格标志
|
||||||
|
|
||||||
| 标志 | 值 | 说明 |
|
| 标志 | 值 | 说明 |
|
||||||
| ------------------------------------ | ----- | -------------------------------------------------------------------------- |
|
| ------------------------------------ | ----- | ------------------------------------------------ |
|
||||||
| `strict` | true | 全局严格模式 |
|
| `strict` | true | 全局严格模式 |
|
||||||
| `noUnusedLocals` | true | 未使用局部变量视为错误 |
|
| `noUnusedLocals` | true | 未使用局部变量视为错误 |
|
||||||
| `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要,如 `handleXxx(store, method, mode)`) |
|
| `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要) |
|
||||||
| `noPropertyAccessFromIndexSignature` | true | 禁止通过点号访问索引签名属性,强制使用括号语法 |
|
| `noPropertyAccessFromIndexSignature` | true | 禁止通过点号访问索引签名属性,强制使用括号语法 |
|
||||||
| `noUncheckedIndexedAccess` | true | 数组/Map 访问必须运行时真值检查 |
|
| `noUncheckedIndexedAccess` | true | 数组/Map 访问必须运行时真值检查 |
|
||||||
| `noImplicitOverride` | true | 子类覆盖父类方法时必须显式使用 `override` 关键字 |
|
| `noImplicitOverride` | true | 子类覆盖父类方法时必须显式使用 `override` 关键字 |
|
||||||
@@ -1008,7 +978,7 @@ bun run check # 一键运行 schema:check + typecheck + lint + test
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run check # 日常开发(类型检查 + lint(含格式) + 单元测试)
|
bun run check # 日常开发(类型检查 + lint(含格式) + 单元测试)
|
||||||
bun run verify # 完整验证(check + 构建 + smoke test)
|
bun run verify # 完整验证(check + 构建)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 已知限制
|
## 已知限制
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -10,15 +10,17 @@ cp probes.example.yaml probes.yaml
|
|||||||
bun run dev probes.yaml
|
bun run dev probes.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
`bun run dev` 会同时启动 Bun 后端和 Vite 前端。开发期请打开 Vite 前端地址 `http://127.0.0.1:5173`。
|
`bun run dev` 启动单进程 fullstack 开发服务器(后端 API + 前端 SPA + HMR),访问 `http://127.0.0.1:3000`。
|
||||||
|
|
||||||
也可以分别运行:
|
## 开发验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev:server probes.yaml
|
bun run check # schema:check + typecheck + lint + bun test
|
||||||
bun run dev:web
|
bun run verify # check + build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`verify` 会基于当前源码重新构建生产 executable。原 smoke test 已移除,executable/E2E 验证后续单独补充。
|
||||||
|
|
||||||
## 配置文件
|
## 配置文件
|
||||||
|
|
||||||
程序通过 YAML 配置文件定义所有运行参数:
|
程序通过 YAML 配置文件定义所有运行参数:
|
||||||
@@ -134,7 +136,7 @@ targets:
|
|||||||
- Command:覆盖命令执行耗时(含 stdout/stderr 读取)
|
- Command:覆盖命令执行耗时(含 stdout/stderr 读取)
|
||||||
- `body`: HTTP 响应体校验(数组,可组合使用)
|
- `body`: HTTP 响应体校验(数组,可组合使用)
|
||||||
- `contains`: 响应体包含的文本
|
- `contains`: 响应体包含的文本
|
||||||
- `regex`: 响应体匹配的正则表达式
|
- `regex`: 响应体匹配的正则表达式(启动期会拒绝嵌套量词等存在 ReDoS 风险的模式)
|
||||||
- `json`: JSONPath 提取值比较
|
- `json`: JSONPath 提取值比较
|
||||||
- `path`: JSONPath 表达式(必填,如 `$.slideshow.title`)
|
- `path`: JSONPath 表达式(必填,如 `$.slideshow.title`)
|
||||||
- 比较操作符(可选,无操作符时仅检查路径对应值是否存在)
|
- 比较操作符(可选,无操作符时仅检查路径对应值是否存在)
|
||||||
@@ -146,7 +148,7 @@ targets:
|
|||||||
- `path`: XPath 表达式(必填,如 `/html/body/h1/text()`)
|
- `path`: XPath 表达式(必填,如 `/html/body/h1/text()`)
|
||||||
- 比较操作符(可选,无操作符时仅检查节点是否存在)
|
- 比较操作符(可选,无操作符时仅检查节点是否存在)
|
||||||
- `stdout` / `stderr`: Command 输出校验(数组,每项为一个操作符对象)
|
- `stdout` / `stderr`: Command 输出校验(数组,每项为一个操作符对象)
|
||||||
- 比较操作符:`equals`(默认)、`contains`、`match`(正则)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`
|
- 比较操作符:`equals`(默认)、`contains`、`match`(正则,启动期会拒绝存在 ReDoS 风险的模式)、`empty`、`exists`、`gte`、`lte`、`gt`、`lt`
|
||||||
|
|
||||||
大小说明:`maxBodyBytes` 和 `maxOutputBytes` 支持单位 `KB`、`MB`、`GB`,也可直接使用数字(非负安全整数字节数)。
|
大小说明:`maxBodyBytes` 和 `maxOutputBytes` 支持单位 `KB`、`MB`、`GB`,也可直接使用数字(非负安全整数字节数)。
|
||||||
|
|
||||||
@@ -163,6 +165,7 @@ JSON Schema:仓库根目录导出 `probe-config.schema.json`,可在 YAML 文
|
|||||||
| 端点 | 说明 |
|
| 端点 | 说明 |
|
||||||
| ----------------------------------------------------------------- | ------------------------------------------------------------ |
|
| ----------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||||
| `GET /health` | 健康检查 |
|
| `GET /health` | 健康检查 |
|
||||||
|
| `GET /api/meta` | 运行时元信息(checker 类型列表) |
|
||||||
| `GET /api/summary` | 总览统计(total/up/down/lastCheckTime) |
|
| `GET /api/summary` | 总览统计(total/up/down/lastCheckTime) |
|
||||||
| `GET /api/targets` | 目标列表及最新状态、分组和采样数据 |
|
| `GET /api/targets` | 目标列表及最新状态、分组和采样数据 |
|
||||||
| `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20` | 指定目标的拨测记录(时间范围 + 分页,`pageSize` 最大 `200`) |
|
| `GET /api/targets/:id/history?from=ISO&to=ISO&page=1&pageSize=20` | 指定目标的拨测记录(时间范围 + 分页,`pageSize` 最大 `200`) |
|
||||||
@@ -172,7 +175,9 @@ JSON Schema:仓库根目录导出 `probe-config.schema.json`,可在 YAML 文
|
|||||||
|
|
||||||
**SummaryResponse**: `total`、`up`、`down`、`lastCheckTime`
|
**SummaryResponse**: `total`、`up`、`down`、`lastCheckTime`
|
||||||
|
|
||||||
**TargetStatus**: `id`、`name`、`type`(http/command)、`target`(URL 或命令摘要)、`group`、`interval`、`latestCheck`、`stats`、`recentSamples`
|
**MetaResponse**: `checkerTypes`(已注册 checker 类型标识符列表)
|
||||||
|
|
||||||
|
**TargetStatus**: `id`、`name`、`type`(checker 类型,如 http/command)、`target`(URL 或命令摘要)、`group`、`interval`、`latestCheck`、`stats`、`recentSamples`
|
||||||
|
|
||||||
**RecentSample**: `timestamp`、`durationMs`、`up`
|
**RecentSample**: `timestamp`、`durationMs`、`up`
|
||||||
|
|
||||||
@@ -197,8 +202,7 @@ API 错误返回 `ApiErrorResponse` 格式:
|
|||||||
| 状态码 | 触发场景 |
|
| 状态码 | 触发场景 |
|
||||||
| ------ | ------------------------------------------------------------------------------------------ |
|
| ------ | ------------------------------------------------------------------------------------------ |
|
||||||
| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数、pageSize 超过 200) |
|
| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数、pageSize 超过 200) |
|
||||||
| 404 | 目标不存在、API 路由未匹配 |
|
| 404 | 目标不存在、API 路由未匹配、非 GET 方法请求 API 路由 |
|
||||||
| 405 | 非 GET 方法请求 API 路由 |
|
|
||||||
|
|
||||||
## 运行参数
|
## 运行参数
|
||||||
|
|
||||||
|
|||||||
80
bun.lock
80
bun.lock
@@ -26,7 +26,6 @@
|
|||||||
"@types/bun": "^1.3.13",
|
"@types/bun": "^1.3.13",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
@@ -40,7 +39,6 @@
|
|||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^8.0.11",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -159,46 +157,12 @@
|
|||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||||
|
|
||||||
"@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=="],
|
|
||||||
|
|
||||||
"@pkgr/core": ["@pkgr/core@0.2.9", "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
|
"@pkgr/core": ["@pkgr/core@0.2.9", "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
|
||||||
|
|
||||||
"@popperjs/core": ["@popperjs/core@2.11.8", "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
|
"@popperjs/core": ["@popperjs/core@2.11.8", "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", { "os": "none", "cpu": "arm64" }, "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ=="],
|
|
||||||
|
|
||||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", { "os": "win32", "cpu": "x64" }, "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg=="],
|
|
||||||
|
|
||||||
"@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=="],
|
|
||||||
|
|
||||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
"@rtsao/scc": ["@rtsao/scc@1.1.0", "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||||
|
|
||||||
"@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "https://registry.npmmirror.com/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="],
|
"@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "https://registry.npmmirror.com/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="],
|
||||||
@@ -319,8 +283,6 @@
|
|||||||
|
|
||||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmmirror.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
|
||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
|
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
@@ -455,8 +417,6 @@
|
|||||||
|
|
||||||
"define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
"define-properties": ["define-properties@1.2.1", "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
|
||||||
|
|
||||||
"doctrine": ["doctrine@2.1.0", "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
"doctrine": ["doctrine@2.1.0", "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||||
|
|
||||||
"dom-helpers": ["dom-helpers@5.2.1", "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
"dom-helpers": ["dom-helpers@5.2.1", "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||||
@@ -567,8 +527,6 @@
|
|||||||
|
|
||||||
"for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
"for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"function.prototype.name": ["function.prototype.name@1.1.8", "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
"function.prototype.name": ["function.prototype.name@1.1.8", "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
||||||
@@ -723,30 +681,6 @@
|
|||||||
|
|
||||||
"levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
"levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
|
||||||
|
|
||||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
|
||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
"lint-staged": ["lint-staged@17.0.4", "https://registry.npmmirror.com/lint-staged/-/lint-staged-17.0.4.tgz", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA=="],
|
"lint-staged": ["lint-staged@17.0.4", "https://registry.npmmirror.com/lint-staged/-/lint-staged-17.0.4.tgz", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA=="],
|
||||||
@@ -777,8 +711,6 @@
|
|||||||
|
|
||||||
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.12", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
|
||||||
|
|
||||||
"napi-postinstall": ["napi-postinstall@0.3.4", "https://registry.npmmirror.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
|
"napi-postinstall": ["napi-postinstall@0.3.4", "https://registry.npmmirror.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
|
||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
@@ -841,8 +773,6 @@
|
|||||||
|
|
||||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
|
|
||||||
|
|
||||||
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
"prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
"prettier": ["prettier@3.8.3", "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||||
@@ -893,8 +823,6 @@
|
|||||||
|
|
||||||
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"safe-array-concat": ["safe-array-concat@1.1.4", "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.4.tgz", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
"safe-array-concat": ["safe-array-concat@1.1.4", "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.4.tgz", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
||||||
|
|
||||||
"safe-push-apply": ["safe-push-apply@1.0.0", "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
"safe-push-apply": ["safe-push-apply@1.0.0", "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||||
@@ -931,8 +859,6 @@
|
|||||||
|
|
||||||
"sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="],
|
"sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="],
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"stable-hash-x": ["stable-hash-x@0.2.0", "https://registry.npmmirror.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
|
"stable-hash-x": ["stable-hash-x@0.2.0", "https://registry.npmmirror.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
|
||||||
|
|
||||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||||
@@ -1003,8 +929,6 @@
|
|||||||
|
|
||||||
"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=="],
|
"victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||||
|
|
||||||
"vite": ["vite@8.0.11", "https://registry.npmmirror.com/vite/-/vite-8.0.11.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
|
|
||||||
|
|
||||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
"whatwg-encoding": ["whatwg-encoding@3.1.1", "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||||
|
|
||||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||||
@@ -1057,8 +981,6 @@
|
|||||||
|
|
||||||
"@reduxjs/toolkit/immer": ["immer@11.1.8", "https://registry.npmmirror.com/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
"@reduxjs/toolkit/immer": ["immer@11.1.8", "https://registry.npmmirror.com/immer/-/immer-11.1.8.tgz", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
||||||
|
|
||||||
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
|
||||||
|
|
||||||
"@tybys/wasm-util/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tybys/wasm-util/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
@@ -1095,8 +1017,6 @@
|
|||||||
|
|
||||||
"prop-types/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"prop-types/react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.18", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", {}, "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw=="],
|
|
||||||
|
|
||||||
"tdesign-react/@babel/runtime": ["@babel/runtime@7.26.10", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.10.tgz", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="],
|
"tdesign-react/@babel/runtime": ["@babel/runtime@7.26.10", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.10.tgz", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="],
|
||||||
|
|
||||||
"tdesign-react/react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
"tdesign-react/react-is": ["react-is@18.3.1", "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型(http、command),target 规模预计增长到 100,checker 类型预计超过 5 种。
|
|
||||||
|
|
||||||
现状问题:
|
|
||||||
1. `GET /api/targets` 对每个 target 单独查询 `getRecentSamples`,产生 N+3 次 SQL 查询
|
|
||||||
2. `ProbeEngine.probeGroup` 中 rejected 结果仅 `console.warn`,前端无法感知异常
|
|
||||||
3. `dev.ts` 和 `scripts/build.ts` 生成的 entry 各自维护相同的启动序列
|
|
||||||
4. `config-loader.ts` 中 `dataDir` 未基于 `configDir` 解析,相对路径依赖进程 cwd
|
|
||||||
5. `validatePagination` 无 pageSize 上限,可被滥用
|
|
||||||
6. `CheckerDefinition` 接口方法参数为 `ResolvedTargetBase`,checker 内部需手动 `as` 断言
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- 消除 targets 路由的 N+1 查询,支撑 100 target 规模
|
|
||||||
- Engine 异常可观测:rejected 结果写入数据库,前端可见
|
|
||||||
- 启动逻辑单一来源,降低维护成本
|
|
||||||
- 修复 dataDir 路径解析 bug
|
|
||||||
- API 防御性:pageSize 上限
|
|
||||||
- CheckerDefinition 泛型化,checker 开发者获得编译期类型安全
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- 不做配置热更新
|
|
||||||
- 不做 API 认证/鉴权
|
|
||||||
- 不做通知/告警系统
|
|
||||||
- 不改变 `ResolvedTargetBase` 的 index signature(registry 层仍用类型擦除)
|
|
||||||
- 不改变前端行为
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: 批量查询 recentSamples 使用 window function
|
|
||||||
|
|
||||||
**选择**:在 `ProbeStore` 中新增 `getAllRecentSamples(limit: number)` 方法,使用 SQLite window function `ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC)` 一次查询所有 target 的最近 N 条采样。
|
|
||||||
|
|
||||||
**替代方案**:
|
|
||||||
- UNION ALL 拼接每个 target 的子查询 → SQL 长度随 target 数线性增长,不可控
|
|
||||||
- 应用层批量(一次查全部再内存分组)→ 数据量大时内存开销高
|
|
||||||
|
|
||||||
**理由**:window function 是 SQLite 3.25+ 原生支持的特性,Bun 内置的 SQLite 版本满足要求。单次查询,SQL 固定长度,性能最优。
|
|
||||||
|
|
||||||
### Decision 2: Engine rejected 写入 internal error 记录
|
|
||||||
|
|
||||||
**选择**:在 `probeGroup` 中,对 `rejected` 的结果构造一条 `matched: false`、`failure: { kind: "error", phase: "internal", path: "engine", message: reason }` 的 check_result 写入 store。
|
|
||||||
|
|
||||||
**替代方案**:
|
|
||||||
- 单独的错误日志表 → 增加 schema 复杂度,前端需要额外查询
|
|
||||||
- 仅保留 console.warn → 现状,不可观测
|
|
||||||
|
|
||||||
**理由**:复用现有 check_results 表和 failure 结构,前端无需改动即可展示异常状态。`phase: "internal"` 区分于正常的 checker 执行失败。通过 `Promise.allSettled` 的索引关联回 target 数组,确保能获取 targetName。
|
|
||||||
|
|
||||||
### Decision 3: 抽取 bootstrap.ts
|
|
||||||
|
|
||||||
**选择**:新增 `src/server/bootstrap.ts`,导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整启动序列:loadConfig → ProbeStore → syncTargets → ProbeEngine → startServer → 注册 shutdown handler。
|
|
||||||
|
|
||||||
**接口设计**:
|
|
||||||
```typescript
|
|
||||||
interface BootstrapOptions {
|
|
||||||
configPath: string;
|
|
||||||
mode: RuntimeMode;
|
|
||||||
staticAssets?: StaticAssets;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`。
|
|
||||||
`build.ts` 生成的 entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })`。
|
|
||||||
|
|
||||||
**替代方案**:
|
|
||||||
- 保持两处重复 → 维护负担随启动逻辑复杂化线性增长
|
|
||||||
|
|
||||||
### Decision 4: dataDir 基于 configDir 解析
|
|
||||||
|
|
||||||
**选择**:在 `config-loader.ts` 的 `loadConfig` 中,对 `dataDir` 使用 `resolve(configDir, dataDir)` 处理。如果 `dataDir` 是绝对路径,`resolve` 会直接返回绝对路径,不影响绝对路径用户。
|
|
||||||
|
|
||||||
**影响**:行为变更——之前相对路径基于 cwd,现在基于配置文件目录。由于项目未上线,无需向前兼容。
|
|
||||||
|
|
||||||
### Decision 5: pageSize 上限 200
|
|
||||||
|
|
||||||
**选择**:在 `middleware.ts` 的 `validatePagination` 中增加 `pageSize > 200` 的校验,返回 400。
|
|
||||||
|
|
||||||
**常量定义**:`MAX_PAGE_SIZE = 200`,定义在 `middleware.ts` 中。
|
|
||||||
|
|
||||||
**理由**:200 条/页对于拨测历史记录的展示场景足够。前端当前使用 20,不受影响。
|
|
||||||
|
|
||||||
### Decision 6: CheckerDefinition 泛型化
|
|
||||||
|
|
||||||
**选择**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
|
|
||||||
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
|
|
||||||
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
|
|
||||||
serialize(target: TResolved): { config: string; target: string };
|
|
||||||
validate(input: CheckerValidationInput): ConfigValidationIssue[];
|
|
||||||
readonly configKey: string;
|
|
||||||
readonly schemas: CheckerSchemas;
|
|
||||||
readonly type: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 默认泛型参数 `= ResolvedTargetBase` 保证 registry 等中间层无需指定泛型
|
|
||||||
- `CheckerRegistry` 内部存储 `CheckerDefinition<ResolvedTargetBase>`(类型擦除)
|
|
||||||
- 各 checker 实现 `implements CheckerDefinition<ResolvedHttpTarget>` 等具体类型
|
|
||||||
- checker 内部 `execute`、`serialize` 方法直接接收具体类型,无需 `as` 断言
|
|
||||||
|
|
||||||
**替代方案**:
|
|
||||||
- Discriminated union → 每加 checker 改 union,违背插件化设计
|
|
||||||
- 维持现状 → 5+ checker 时 `as` 断言散落各处
|
|
||||||
|
|
||||||
**影响范围**:
|
|
||||||
- `runner/types.ts`:接口加泛型参数
|
|
||||||
- `runner/registry.ts`:内部 Map 类型为 `CheckerDefinition`(使用默认参数)
|
|
||||||
- `http/execute.ts`、`command/execute.ts`:`implements CheckerDefinition<具体类型>`,移除方法内的 `as` 断言
|
|
||||||
- `engine.ts`、`config-loader.ts`、`store.ts`:不变(依赖 base interface)
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
- **window function 兼容性** → Bun 内置 SQLite >= 3.25,已验证支持。如果未来需要外部 SQLite,需确认版本。
|
|
||||||
- **Engine rejected 写入依赖索引关联** → 通过 `Promise.allSettled` 的索引关联回 target 数组获取 targetName。前提是 `probeGroup` 的 targets 数组与 `Promise.allSettled` 结果数组保持一一对应,当前实现满足此条件。
|
|
||||||
- **bootstrap.ts 增加一层间接** → 启动流程从 2 处直接代码变为 1 处函数调用。复杂度不增加,只是位置移动。
|
|
||||||
- **泛型擦除在 registry 层** → `registry.get()` 返回 `CheckerDefinition`(base 类型),engine 调用时仍是 base 类型。这是设计意图:中间层不感知具体 checker 类型。
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
后端在 target 规模增长(预计到 100)和 checker 类型扩展(预计超过 5 种)的趋势下,存在查询性能瓶颈、可观测性盲区、启动逻辑重复、路径解析 bug 和类型安全不足等问题。本次变更集中修复这些架构短板,为后续扩展打好基础。
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- **targets 路由 N+1 查询优化**:`handleTargets` 中对每个 target 单独调用 `getRecentSamples` 改为批量查询,消除 N 次独立 SQL
|
|
||||||
- **Engine rejected 结果持久化**:`probeGroup` 中 `Promise.allSettled` 的 rejected 结果写入 `matched: false` 的 check_result(failure 标记为 internal error),替代仅 `console.warn`
|
|
||||||
- **启动逻辑统一**:抽取 `bootstrap.ts`,`dev.ts` 和 build 生成的 entry 共用同一启动序列,消除重复
|
|
||||||
- **dataDir 相对路径修复**:`config-loader.ts` 中用 `resolve(configDir, dataDir)` 处理相对路径,确保从任意 cwd 启动时数据库位置一致
|
|
||||||
- **validatePagination 加 pageSize 上限**:限制最大 pageSize 为 200,超出返回 400
|
|
||||||
- **CheckerDefinition 泛型化**:为 `CheckerDefinition` 加泛型参数 `<TResolved extends ResolvedTargetBase>`,checker 内部获得完整类型安全,registry 用类型擦除保持解耦
|
|
||||||
- **availability 精度统一**:`getAllTargetStats` 和 `getTargetStats` 的 availability 计算精度不一致,统一为相同的四舍五入策略
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
|
|
||||||
- `server-bootstrap`: 统一的服务启动引导流程,dev 和 production 共用
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
|
|
||||||
- `batch-data-queries`: 新增 `getAllRecentSamples` 批量采样查询,消除 targets 路由的 N+1 问题;修复 availability 精度不一致
|
|
||||||
- `probe-engine`: Engine 对 rejected 结果写入 matched:false 记录而非静默丢弃
|
|
||||||
- `probe-config`: dataDir 相对路径基于 configDir 解析
|
|
||||||
- `probe-api`: validatePagination 增加 pageSize 上限校验
|
|
||||||
- `checker-runner-abstraction`: CheckerDefinition 接口泛型化,checker 内部类型安全
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- **代码**:`src/server/` 下约 8 个文件变更,新增 `bootstrap.ts` 和 `store.ts` 的批量查询方法;另修复 `src/web/components/ErrorBoundary.tsx` 的 `override` 标记(typecheck 前置修复)
|
|
||||||
- **API**:pageSize 超过 200 时返回 400(新增约束,当前前端未使用超大 pageSize)
|
|
||||||
- **构建**:`scripts/build.ts` 生成的 entry 改为调用 bootstrap
|
|
||||||
- **测试**:需新增/更新 engine、store、middleware、bootstrap 相关测试
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: 批量查询所有目标的最近采样数据
|
|
||||||
系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map<number, Array<{ timestamp: string; duration_ms: number | null; matched: number }>>` 结构。
|
|
||||||
|
|
||||||
#### Scenario: 获取所有目标的最近采样
|
|
||||||
- **WHEN** 调用 `getAllRecentSamples(30)`
|
|
||||||
- **THEN** 系统 SHALL 通过单次 SQL 查询获取每个 target 最近 30 条记录,返回按 target_id 索引的 Map
|
|
||||||
|
|
||||||
#### Scenario: 目标无历史记录
|
|
||||||
- **WHEN** 某 target 在 check_results 表中无任何记录
|
|
||||||
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
|
||||||
|
|
||||||
#### Scenario: 采样数据排序
|
|
||||||
- **WHEN** 获取采样数据
|
|
||||||
- **THEN** 每个 target 的记录 SHALL 按 timestamp 降序排列(最新在前)
|
|
||||||
|
|
||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: targets 列表使用批量方法
|
|
||||||
`handleTargets`(routes/targets.ts 中生成 TargetStatus[] 的逻辑)SHALL 使用 `getLatestChecksMap`、`getAllTargetStats` 和 `getAllRecentSamples` 替代逐目标查询,消除 N+1 查询。
|
|
||||||
|
|
||||||
#### Scenario: 目标列表使用批量查询
|
|
||||||
- **WHEN** 处理 `GET /api/targets` 请求
|
|
||||||
- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()`、`getAllTargetStats()`、`getAllRecentSamples(30)` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库
|
|
||||||
|
|
||||||
#### Scenario: 目标无采样数据
|
|
||||||
- **WHEN** 某 target 在 getAllRecentSamples 返回的 Map 中不存在
|
|
||||||
- **THEN** 该 target 的 recentSamples SHALL 为空数组
|
|
||||||
|
|
||||||
### Requirement: 批量查询目标统计
|
|
||||||
系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。availability 计算精度 SHALL 与 `getTargetStats` 一致,统一使用 `Math.round(value * 100) / 100` 保留两位小数。
|
|
||||||
|
|
||||||
#### Scenario: 获取所有目标的聚合统计
|
|
||||||
- **WHEN** 调用 `getAllTargetStats()`
|
|
||||||
- **THEN** 系统 SHALL 执行单次 GROUP BY 聚合查询,在内存中计算 availability 并返回 `Map<number, { totalChecks, availability }>`
|
|
||||||
|
|
||||||
#### Scenario: availability 精度
|
|
||||||
- **WHEN** 计算 availability(upCount / totalChecks * 100)
|
|
||||||
- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致
|
|
||||||
|
|
||||||
#### Scenario: 目标无历史记录
|
|
||||||
- **WHEN** 某 target 在 check_results 表中无任何记录
|
|
||||||
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: Checker 接口定义
|
|
||||||
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。
|
|
||||||
|
|
||||||
#### Scenario: Checker 接口包含必要方法
|
|
||||||
- **WHEN** 开发者实现一个新的 Checker
|
|
||||||
- **THEN** 该实现 MUST 提供 `type`(字符串标识)、`configKey`(配置分组名)、TypeBox 配置契约、启动期语义校验、`resolve(target, context): TResolved`(解析配置并填充默认值)、`execute(target: TResolved, ctx)`(执行探测返回 CheckResult)和 `serialize(target: TResolved)`(返回 target 展示文本和 config JSON)
|
|
||||||
|
|
||||||
#### Scenario: CheckerContext 注入 signal
|
|
||||||
- **WHEN** 引擎调用 `checker.execute(target, ctx)`
|
|
||||||
- **THEN** `ctx.signal` SHALL 是一个由引擎创建的 `AbortSignal`,在超时或引擎关闭时 abort
|
|
||||||
|
|
||||||
#### Scenario: resolve 不承担通用契约校验
|
|
||||||
- **WHEN** config-loader 调用 checker.resolve()
|
|
||||||
- **THEN** checker.resolve() SHALL 假定配置已经通过 TypeBox/Ajv 契约校验和启动期语义校验,只负责默认值填充、路径解析和领域配置转换
|
|
||||||
|
|
||||||
#### Scenario: 接口方法使用泛型约束
|
|
||||||
- **WHEN** 开发者查看 `CheckerDefinition<TResolved>` 接口签名
|
|
||||||
- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved`
|
|
||||||
|
|
||||||
#### Scenario: checker 实现无需手动断言
|
|
||||||
- **WHEN** HttpChecker 实现 `CheckerDefinition<ResolvedHttpTarget>`
|
|
||||||
- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言
|
|
||||||
|
|
||||||
#### Scenario: registry 使用默认泛型参数
|
|
||||||
- **WHEN** CheckerRegistry 存储和返回 checker 实例
|
|
||||||
- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition<ResolvedTargetBase>`),实现类型擦除
|
|
||||||
|
|
||||||
### Requirement: CheckerRegistry 注册中心
|
|
||||||
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。
|
|
||||||
|
|
||||||
#### Scenario: 注册并获取 Checker
|
|
||||||
- **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")`
|
|
||||||
- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例(类型为 `CheckerDefinition`)
|
|
||||||
|
|
||||||
#### Scenario: 获取未注册的 type
|
|
||||||
- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker
|
|
||||||
- **THEN** 系统 SHALL 抛出错误,提示不支持的 probe type
|
|
||||||
|
|
||||||
#### Scenario: 重复注册
|
|
||||||
- **WHEN** 同一 type 值被重复 `register()`
|
|
||||||
- **THEN** 系统 SHALL 抛出错误,提示该 type 已注册
|
|
||||||
|
|
||||||
#### Scenario: 查询支持的 type 列表
|
|
||||||
- **WHEN** 注册了 "http" 和 "command" 两个 checker 后查询 `registry.supportedTypes`
|
|
||||||
- **THEN** 返回的数组 SHALL 包含 `["http", "command"]`(按注册顺序)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: API 错误处理
|
|
||||||
系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。
|
|
||||||
|
|
||||||
#### Scenario: 查询不存在的目标
|
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/999/history`
|
|
||||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
|
||||||
|
|
||||||
#### Scenario: 无效的 from/to 参数
|
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=invalid`
|
|
||||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
|
||||||
|
|
||||||
#### Scenario: 无效的分页参数
|
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc`
|
|
||||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
|
||||||
|
|
||||||
#### Scenario: pageSize 超过上限
|
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=201`
|
|
||||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息,提示 pageSize 不能超过 200
|
|
||||||
|
|
||||||
#### Scenario: pageSize 等于上限
|
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=200`
|
|
||||||
- **THEN** 系统 SHALL 正常返回数据
|
|
||||||
|
|
||||||
#### Scenario: from 或 to 参数缺失
|
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
|
|
||||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
|
||||||
|
|
||||||
#### Scenario: 无效的目标 ID
|
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/abc/history`
|
|
||||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: 数据目录路径解析
|
|
||||||
配置加载流程 SHALL 将 `server.dataDir` 相对路径基于配置文件所在目录(configDir)解析为绝对路径。绝对路径 SHALL 保持不变。
|
|
||||||
|
|
||||||
#### Scenario: dataDir 为相对路径
|
|
||||||
- **WHEN** 配置文件位于 `/opt/dial/probes.yaml`,且 `server.dataDir` 配置为 `./data`
|
|
||||||
- **THEN** 系统 SHALL 将 dataDir 解析为 `/opt/dial/data`,而非依赖进程 cwd
|
|
||||||
|
|
||||||
#### Scenario: dataDir 为绝对路径
|
|
||||||
- **WHEN** `server.dataDir` 配置为 `/var/lib/dial/data`
|
|
||||||
- **THEN** 系统 SHALL 直接使用该绝对路径,不做额外解析
|
|
||||||
|
|
||||||
#### Scenario: dataDir 使用默认值
|
|
||||||
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`)
|
|
||||||
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: 组内并发拨测
|
|
||||||
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。当某个目标的 checker 执行 rejected(非正常 CheckResult 返回,而是 Promise reject)时,系统 SHALL 将该异常记录为 `matched: false` 的 check_result,而非仅 console.warn。
|
|
||||||
|
|
||||||
#### Scenario: 同组目标并发执行
|
|
||||||
- **WHEN** 调度器触发一次 tick,该组有 3 个目标,且全局并发余量至少为 3
|
|
||||||
- **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行
|
|
||||||
|
|
||||||
#### Scenario: 单个目标失败不影响同组其他目标
|
|
||||||
- **WHEN** 同组中某个目标的检查请求超时或失败(checker 正常返回 CheckResult)
|
|
||||||
- **THEN** 其他目标的检查 SHALL 正常完成并记录结果
|
|
||||||
|
|
||||||
#### Scenario: 同组中某个目标的 checker 执行 rejected
|
|
||||||
- **WHEN** 同组中某个目标的 checker 执行抛出未捕获异常(Promise rejected)
|
|
||||||
- **THEN** 系统 SHALL 为该目标写入一条 `matched: false` 的 check_result,failure 为 `{ kind: "error", phase: "internal", path: "engine", message: <rejected reason> }`,其他目标的检查 SHALL 不受影响
|
|
||||||
|
|
||||||
#### Scenario: rejected 结果通过索引关联 targetName
|
|
||||||
- **WHEN** checker 执行 rejected
|
|
||||||
- **THEN** 系统 SHALL 通过 Promise.allSettled 的索引关联回 target 数组,获取对应的 targetName 用于写入 check_result
|
|
||||||
|
|
||||||
#### Scenario: 全局并发限制生效
|
|
||||||
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
|
|
||||||
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
## 1. CheckerDefinition 泛型化
|
|
||||||
|
|
||||||
- [x] 1.1 修改 `src/server/checker/runner/types.ts`:为 CheckerDefinition 接口添加泛型参数 `<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,约束 execute、resolve、serialize 方法的 target 参数类型
|
|
||||||
- [x] 1.2 修改 `src/server/checker/runner/registry.ts`:内部 Map 类型使用 `CheckerDefinition`(默认泛型参数),确保类型擦除
|
|
||||||
- [x] 1.3 修改 `src/server/checker/runner/http/execute.ts`:HttpChecker 实现 `CheckerDefinition<ResolvedHttpTarget>`,移除 execute/serialize 方法内的 `as ResolvedHttpTarget` 断言(resolve 方法内对 RawTargetConfig 的断言保留,泛型不覆盖输入参数窄化)
|
|
||||||
- [x] 1.4 修改 `src/server/checker/runner/command/execute.ts`:CommandChecker 实现 `CheckerDefinition<ResolvedCommandTarget>`,移除 execute/serialize 方法内的 `as ResolvedCommandTarget` 断言(resolve 方法内对 RawTargetConfig 的断言保留)
|
|
||||||
- [x] 1.5 修复 `src/web/components/ErrorBoundary.tsx` 的 `override` 标记(`noImplicitOverride` 规则要求的既有代码修复),运行 `bun run typecheck` 确认类型系统无错误
|
|
||||||
|
|
||||||
## 2. ProbeStore 批量查询优化
|
|
||||||
|
|
||||||
- [x] 2.1 在 `src/server/checker/store.ts` 中新增 `getAllRecentSamples(limit: number)` 方法,使用 `ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC)` 实现单次批量查询
|
|
||||||
- [x] 2.2 修改 `src/server/checker/store.ts` 中 `getAllTargetStats` 的 availability 计算:将 `Math.round((row.upCount / row.totalChecks) * 10000) / 100` 改为 `Math.round((row.upCount / row.totalChecks) * 100 * 100) / 100`,与 `getTargetStats` 精度一致
|
|
||||||
- [x] 2.3 修改 `src/server/routes/targets.ts`:`handleTargets` 使用 `store.getAllRecentSamples(30)` 替代循环调用 `store.getRecentSamples`
|
|
||||||
- [x] 2.4 在 `tests/server/checker/store.test.ts` 中新增 `getAllRecentSamples` 的单元测试和 availability 精度一致性测试
|
|
||||||
|
|
||||||
## 3. Engine rejected 结果持久化
|
|
||||||
|
|
||||||
- [x] 3.1 修改 `src/server/checker/engine.ts`:`probeGroup` 中对 rejected 结果通过索引关联 target,构造 `matched: false`、`failure: { kind: "error", phase: "internal", path: "engine", message }` 的 check_result 写入 store
|
|
||||||
- [x] 3.2 在 `tests/server/checker/engine.test.ts` 中新增 rejected 结果写入的测试用例
|
|
||||||
|
|
||||||
## 4. 启动逻辑统一
|
|
||||||
|
|
||||||
- [x] 4.1 新增 `src/server/bootstrap.ts`,导出 `bootstrap(options: BootstrapOptions)` 函数,封装 loadConfig → ProbeStore → syncTargets → ProbeEngine → startServer → shutdown handler 完整序列
|
|
||||||
- [x] 4.2 修改 `src/server/dev.ts`:改为调用 `bootstrap({ configPath, mode: "development" })`
|
|
||||||
- [x] 4.3 修改 `scripts/build.ts`:生成的 server entry 改为调用 `bootstrap({ configPath, mode: "production", staticAssets })`
|
|
||||||
- [x] 4.4 在 `tests/server/` 中新增 bootstrap 相关测试
|
|
||||||
|
|
||||||
## 5. dataDir 路径修复
|
|
||||||
|
|
||||||
- [x] 5.1 修改 `src/server/checker/config-loader.ts`:对 dataDir 使用 `resolve(configDir, dataDir)` 处理相对路径
|
|
||||||
- [x] 5.2 在 `tests/server/checker/config-loader.test.ts` 中新增 dataDir 路径解析的测试用例
|
|
||||||
|
|
||||||
## 6. pageSize 上限
|
|
||||||
|
|
||||||
- [x] 6.1 修改 `src/server/middleware.ts`:`validatePagination` 增加 `pageSize > 200` 的校验,返回 400
|
|
||||||
- [x] 6.2 在 `tests/server/app.test.ts` 中新增 pageSize 超限的测试用例
|
|
||||||
|
|
||||||
## 7. 质量保障与文档
|
|
||||||
|
|
||||||
- [x] 7.1 运行 `bun run check`(schema:check + typecheck + lint + test)确认全部通过
|
|
||||||
- [x] 7.2 运行 `bun run build` 确认构建成功
|
|
||||||
- [x] 7.3 更新 DEVELOPMENT.md 中相关章节(bootstrap 启动流程、CheckerDefinition 泛型说明、pageSize 上限说明)
|
|
||||||
112
openspec/changes/cmd-checker-enhancement/design.md
Normal file
112
openspec/changes/cmd-checker-enhancement/design.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
当前 command checker 使用 `"command"` 作为 type 和 configKey,对应源码目录 `src/server/checker/runner/command/`、测试目录 `tests/server/checker/runner/command/`、spec 目录 `openspec/specs/command-checker/`。
|
||||||
|
|
||||||
|
测试中使用了 `true`、`false`、`sleep`、`bash`、`yes | head` 等 Unix 系统命令,在纯 Windows 环境(无 Git Bash)下无法运行。probes.example.yaml 中的示例命令(`uname -a`、`ls /tmp`、`date`)同样不跨平台。
|
||||||
|
|
||||||
|
项目未上线,无向前兼容负担。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- 将 type/configKey 从 `"command"` 统一重命名为 `"cmd"`,包括源码目录、测试目录、spec 目录、YAML 配置键名
|
||||||
|
- 测试改用 `bun -e "..."` 替代系统命令,确保 Windows/macOS/Linux 三平台通过
|
||||||
|
- probes.example.yaml 提供跨平台示例
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- 不加 shell 模式(现有 exec + args 已覆盖所有 shell 场景)
|
||||||
|
- 不加重试机制(失败是拨测指标)
|
||||||
|
- 不精简 resolve() 中 intervalMs/timeoutMs(收益小,接口改动大)
|
||||||
|
- 不加 successExitCodes 别名(已有 expect.exitCode)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: type 与 configKey 统一为 `cmd`
|
||||||
|
|
||||||
|
YAML 配置形态变为:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
defaults:
|
||||||
|
cmd:
|
||||||
|
maxOutputBytes: "100MB"
|
||||||
|
|
||||||
|
targets:
|
||||||
|
- name: "test"
|
||||||
|
type: cmd
|
||||||
|
cmd:
|
||||||
|
exec: "bun"
|
||||||
|
args: ["-e", "console.log('hello')"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由:** `cmd` 简洁,且 type 与 configKey 保持一致(与 HTTP checker 的 `http`/`http` 对称)。
|
||||||
|
|
||||||
|
**替代方案:** 只改 type 不改 configKey → 会出现 `type: cmd` + `command: {...}` 的不一致,否决。
|
||||||
|
|
||||||
|
### D2: 内部属性名统一为 `cmd`
|
||||||
|
|
||||||
|
`ResolvedCommandTarget` 接口中的 `command` 属性名也改为 `cmd`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
interface ResolvedCommandTarget {
|
||||||
|
command: ResolvedCommandConfig;
|
||||||
|
type: "command";
|
||||||
|
}
|
||||||
|
// t.command.exec
|
||||||
|
|
||||||
|
// After
|
||||||
|
interface ResolvedCommandTarget {
|
||||||
|
cmd: ResolvedCommandConfig;
|
||||||
|
type: "cmd";
|
||||||
|
}
|
||||||
|
// t.cmd.exec
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由:** 内外一致,避免 configKey 是 `cmd` 但内部属性是 `command` 的割裂。
|
||||||
|
|
||||||
|
### D3: 源码目录重命名 `runner/command/` → `runner/cmd/`
|
||||||
|
|
||||||
|
所有 import 路径同步更新。测试目录 `tests/server/checker/runner/command/` → `tests/server/checker/runner/cmd/`。
|
||||||
|
|
||||||
|
**理由:** 目录名与 type/configKey 保持一致,降低认知负担。
|
||||||
|
|
||||||
|
### D3: 跨平台测试命令替换表
|
||||||
|
|
||||||
|
| 原命令 | 替换为 |
|
||||||
|
|---|---|
|
||||||
|
| `true` | `bun -e "process.exit(0)"` |
|
||||||
|
| `false` | `bun -e "process.exit(1)"` |
|
||||||
|
| `echo hello` | `bun -e "console.log('hello')"` |
|
||||||
|
| `sleep 10` | `bun -e "await Bun.sleep(10000)"` |
|
||||||
|
| `bash -c "echo error >&2"` | `bun -e "process.stderr.write('error\n')"` |
|
||||||
|
| `bash -c "yes \| head -1000"` | `bun -e "process.stdout.write('y\n'.repeat(1000))"` |
|
||||||
|
|
||||||
|
**理由:** `bun` 是项目唯一运行时依赖,三平台均可用,无需额外安装。
|
||||||
|
|
||||||
|
### D4: probes.example.yaml 示例策略
|
||||||
|
|
||||||
|
示例命令改用 `bun -e "..."` 或跨平台命令(如 `bun --version`),不再使用 `uname`、`ls /tmp` 等 Unix 专属命令。
|
||||||
|
|
||||||
|
### D5: spec 目录重命名
|
||||||
|
|
||||||
|
`openspec/specs/command-checker/` → `openspec/specs/cmd-checker/`,与 type 名称对齐。
|
||||||
|
|
||||||
|
### D6: 不加 shell 模式
|
||||||
|
|
||||||
|
用户需要管道/重定向时,用现有参数即可:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cmd:
|
||||||
|
exec: "/bin/bash"
|
||||||
|
args: ["-c", "df -h | grep /dev/sda1"]
|
||||||
|
```
|
||||||
|
|
||||||
|
shell 模式本质是语法糖——内部仍然是 `Bun.spawn([shell, "-c", exec])`。增加代码复杂度(shell 检测、参数推断、互斥校验)但收益有限。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [全量重命名可能遗漏引用] → 通过全局搜索 `"command"` 字面量确保无遗漏,CI 类型检查兜底
|
||||||
|
- [测试中 `bun -e` 启动开销比原生命令大] → 拨测场景不敏感,测试可接受毫秒级差异
|
||||||
|
- [probes.example.yaml 示例不如 Unix 命令直观] → 加注释说明用途,保持可读性
|
||||||
34
openspec/changes/cmd-checker-enhancement/proposal.md
Normal file
34
openspec/changes/cmd-checker-enhancement/proposal.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
`command` 作为 checker type 名称过长,且测试依赖 Unix 系统命令导致 Windows 环境无法运行。需要统一重命名为 `cmd` 并实现跨平台测试适配。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **BREAKING** type 字面量 `"command"` → `"cmd"`,configKey `"command"` → `"cmd"`
|
||||||
|
- **BREAKING** YAML 配置中 `type: command` → `type: cmd`,`command:` 块 → `cmd:` 块
|
||||||
|
- **BREAKING** `defaults.command` → `defaults.cmd`
|
||||||
|
- 源码目录 `runner/command/` → `runner/cmd/`
|
||||||
|
- spec 目录 `command-checker/` → `cmd-checker/`
|
||||||
|
- 测试全部改用 `bun -e "..."` 替代系统命令(true/false/sleep/bash)
|
||||||
|
- probes.example.yaml 更新为跨平台示例
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
(无)
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `probe-config`: `type: command` → `type: cmd`,`command` 分组 → `cmd` 分组,`defaults.command` → `defaults.cmd`,所有校验中的 `"command"` 字面量更新
|
||||||
|
- `command-checker`: type/configKey 重命名为 `cmd`,spec 目录重命名为 `cmd-checker`
|
||||||
|
- `checker-runner-abstraction`: registry 注册的 type 从 `"command"` 变为 `"cmd"`,`supportedTypes` 返回 `["http", "cmd"]`
|
||||||
|
- `windows-test-compat`: 测试命令全面改用 `bun -e "..."`,probes.example.yaml 使用跨平台示例
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- 后端:`src/server/checker/runner/command/` 整个目录重命名及内部所有 `"command"` 字面量
|
||||||
|
- 配置:probes.example.yaml、probe-config.schema.json 中的 type 枚举和分组名
|
||||||
|
- 测试:`tests/server/checker/runner/command/` 目录重命名及测试命令替换
|
||||||
|
- 前端:无影响(动态显示 type 值)
|
||||||
|
- 数据库:stored_targets.type 列值变更(项目未上线,无迁移负担)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: CheckerRegistry 注册中心
|
||||||
|
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。
|
||||||
|
|
||||||
|
#### Scenario: 查询支持的 type 列表
|
||||||
|
- **WHEN** 注册了 "http" 和 "cmd" 两个 checker 后查询 `registry.supportedTypes`
|
||||||
|
- **THEN** 返回的数组 SHALL 包含 `["http", "cmd"]`(按注册顺序)
|
||||||
|
|
||||||
|
### Requirement: Command checker 提供契约片段
|
||||||
|
系统 SHALL 支持 checker 提供自身 TypeBox 配置契约片段,用于描述该 checker 的 defaults 分组、target 领域分组和 expect 分组。
|
||||||
|
|
||||||
|
#### Scenario: Cmd checker 提供契约片段
|
||||||
|
- **WHEN** Cmd checker 被注册
|
||||||
|
- **THEN** registry SHALL 能提供 Cmd defaults、Cmd target 和 Cmd expect 的 TypeBox 契约片段
|
||||||
|
|
||||||
|
### Requirement: 配置解析通过 registry 委托 checker
|
||||||
|
系统 SHALL 在 `config-loader.ts` 的配置加载流程中通过 `checkerRegistry` 发现已注册 checker,组合公共 TypeBox 契约与 checker 契约,并将 checker 专属语义校验和解析委托给对应 checker。
|
||||||
|
|
||||||
|
#### Scenario: 配置解析委托 checker
|
||||||
|
- **WHEN** config-loader 解析一个 type 为 "cmd" 的 target
|
||||||
|
- **THEN** config-loader SHALL 调用 `checkerRegistry.get("cmd")` 获取对应 checker,并委托该 checker 执行语义校验和 resolve
|
||||||
|
|
||||||
|
### Requirement: Command text 断言位于 Cmd 目录
|
||||||
|
系统 SHALL 在 checker 专用目录中提供 text 断言函数。
|
||||||
|
|
||||||
|
#### Scenario: Command text 断言位于 Cmd 目录
|
||||||
|
- **WHEN** Cmd checker 需要对 stdout/stderr 执行文本规则校验
|
||||||
|
- **THEN** SHALL 调用 `runner/cmd/text.ts` 中的 `checkTextRules(text, rules, phase)`
|
||||||
|
|
||||||
|
### Requirement: Command 专用 expect
|
||||||
|
系统 SHALL 在 checker 专用目录中提供 exitCode 断言函数。
|
||||||
|
|
||||||
|
#### Scenario: Command 专用 expect
|
||||||
|
- **WHEN** Cmd checker 需要校验退出码
|
||||||
|
- **THEN** SHALL 调用 `runner/cmd/expect.ts` 中的 `checkExitCode()`
|
||||||
|
|
||||||
|
### Requirement: Checker 接口定义
|
||||||
|
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。
|
||||||
|
|
||||||
|
#### Scenario: type 与 configKey 默认一致
|
||||||
|
- **WHEN** checker 定义 `type: "cmd"`
|
||||||
|
- **THEN** checker 的 `configKey` SHALL 默认使用 `"cmd"`,对应 target 的 `cmd` 分组和 defaults.cmd 分组
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: command target 配置
|
||||||
|
系统 SHALL 支持 `type: cmd` 的 target 配置,通过 `cmd.exec` 和 `cmd.args` 描述本地命令,并使用 cmd 专用字段配置工作目录、环境变量和输出限制。
|
||||||
|
|
||||||
|
#### Scenario: 解析 cmd target
|
||||||
|
- **WHEN** YAML 中 target 配置 `type: cmd`、`cmd.exec: "pgrep"` 和 `cmd.args: ["nginx"]`
|
||||||
|
- **THEN** 系统 SHALL 将其解析为 cmd checker,并保留 exec、args、cwd、env、maxOutputBytes、interval、timeout 和 expect 配置
|
||||||
|
|
||||||
|
#### Scenario: cmd target 缺少 exec
|
||||||
|
- **WHEN** YAML 中 target 配置 `type: cmd` 但缺少 `cmd.exec`
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,并提示该 target 缺少 cmd.exec 字段
|
||||||
|
|
||||||
|
#### Scenario: cwd 相对配置文件目录解析
|
||||||
|
- **WHEN** cmd target 配置 `cmd.cwd: "scripts"` 且配置文件位于 `/opt/checker/probes.yaml`
|
||||||
|
- **THEN** 系统 SHALL 将 cwd 解析为 `/opt/checker/scripts`
|
||||||
|
|
||||||
|
#### Scenario: cmd 不使用 shell
|
||||||
|
- **WHEN** cmd target 配置 `exec` 和 `args`
|
||||||
|
- **THEN** 系统 MUST 直接执行该程序和参数,不通过 shell 解释整段命令字符串
|
||||||
|
|
||||||
|
#### Scenario: env 默认继承并允许覆盖
|
||||||
|
- **WHEN** cmd target 配置 `cmd.env: {LANG: "C"}` 且当前进程环境包含 `PATH`
|
||||||
|
- **THEN** 系统 SHALL 继承当前进程的全部环境变量,并将 `LANG` 覆盖为 `"C"`
|
||||||
|
|
||||||
|
### Requirement: command checker 执行
|
||||||
|
系统 SHALL 按 cmd target 配置执行本地命令,记录执行耗时、退出码、stdout 和 stderr,并在执行失败时产生结构化错误信息。
|
||||||
|
|
||||||
|
### Requirement: command expect 校验
|
||||||
|
系统 SHALL 支持 cmd 专用 expect,包括 `exitCode`、`stdout` 和 `stderr`,并按 exitCode、duration、stdout、stderr 的阶段顺序快速失败。
|
||||||
|
|
||||||
|
### Requirement: command checker 启动期配置校验
|
||||||
|
系统 SHALL 在启动期对 cmd checker 的配置契约和语义执行严格校验。Cmd target 的 `cmd` 分组 SHALL 只允许 `exec`、`args`、`cwd`、`env`、`maxOutputBytes` 字段;Cmd expect SHALL 只允许 `exitCode`、`maxDurationMs`、`stdout`、`stderr` 字段。未知字段、非法类型和不可编译正则 MUST 导致启动期配置错误。`expect.exitCode` SHALL 保留原有有限整数数组语义,不限制到特定平台范围。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: YAML 配置文件格式
|
||||||
|
target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,cmd 领域字段 MUST 放在 `cmd` 分组。
|
||||||
|
|
||||||
|
#### Scenario: 最简 command 配置文件解析
|
||||||
|
- **WHEN** 系统读取只包含一个 `type: cmd` target 和 `cmd.exec` 的 YAML 配置文件
|
||||||
|
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB)
|
||||||
|
|
||||||
|
### Requirement: 配置校验
|
||||||
|
|
||||||
|
#### Scenario: command target 缺少 exec
|
||||||
|
- **WHEN** YAML 中某个 target 配置 `type: cmd` 但缺少 `cmd.exec`
|
||||||
|
- **THEN** 系统 SHALL 以错误退出,提示该 target 缺少 cmd.exec 字段
|
||||||
|
|
||||||
|
#### Scenario: 动态 env 字段允许
|
||||||
|
- **WHEN** YAML 中 `cmd.env` 包含任意环境变量名称,且对应值为字符串
|
||||||
|
- **THEN** 系统 SHALL 接受这些动态 env 名称
|
||||||
|
|
||||||
|
### Requirement: expect 配置增强
|
||||||
|
系统 SHALL 支持 typed target 的领域专用 expect 配置,包括 HTTP 的 `status`、`headers`、`body` 和 cmd 的 `exitCode`、`stdout`、`stderr`。
|
||||||
|
|
||||||
|
#### Scenario: 解析 command expect 配置
|
||||||
|
- **WHEN** YAML 配置文件中 cmd target 的 expect 包含 exitCode、stdout 和 stderr 规则数组
|
||||||
|
- **THEN** 系统 SHALL 正确解析并存储为 cmd target 的 expect 字段
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 命令检测器测试 SHALL 使用跨平台命令
|
||||||
|
命令检测器的测试 SHALL 使用 `bun -e` 脚本替代所有系统命令(包括 `true`、`false`、`sleep`、`bash`、`echo`、`yes | head`),确保测试在 Windows、macOS、Linux 三平台上行为一致。
|
||||||
|
|
||||||
|
#### Scenario: 进程退出码 0
|
||||||
|
- **WHEN** 测试需要一个正常退出的命令
|
||||||
|
- **THEN** 测试 SHALL 使用 `bun -e "process.exit(0)"` 替代 `true`
|
||||||
|
|
||||||
|
#### Scenario: 进程退出码非零
|
||||||
|
- **WHEN** 测试需要一个失败退出的命令
|
||||||
|
- **THEN** 测试 SHALL 使用 `bun -e "process.exit(1)"` 替代 `false`
|
||||||
|
|
||||||
|
#### Scenario: stdout 输出
|
||||||
|
- **WHEN** 测试需要一个输出文本到 stdout 的命令
|
||||||
|
- **THEN** 测试 SHALL 使用 `bun -e "console.log('text')"` 替代 `echo text`
|
||||||
|
|
||||||
|
#### Scenario: stderr 输出
|
||||||
|
- **WHEN** 测试需要一个输出文本到 stderr 的命令
|
||||||
|
- **THEN** 测试 SHALL 使用 `bun -e "process.stderr.write('error\n')"` 替代 `bash -c "echo error >&2"`
|
||||||
|
|
||||||
|
#### Scenario: 长时间运行命令
|
||||||
|
- **WHEN** 测试需要一个超时场景的长时间运行命令
|
||||||
|
- **THEN** 测试 SHALL 使用 `bun -e "await Bun.sleep(10000)"` 替代 `sleep 10`
|
||||||
|
|
||||||
|
#### Scenario: 大量输出
|
||||||
|
- **WHEN** 测试需要一个产生大量输出的命令
|
||||||
|
- **THEN** 测试 SHALL 使用 `bun -e "process.stdout.write('y\n'.repeat(N))"` 替代 `bash -c "yes | head -N"`
|
||||||
|
|
||||||
|
#### Scenario: 验证非 shell 模式下特殊字符不被展开
|
||||||
|
- **WHEN** 通过 `Bun.spawn` 执行 `bun -e "console.log('*')"` 并检查 stdout 包含 `*`
|
||||||
|
- **THEN** 测试 SHALL 在 Windows、macOS 和 Linux 上均返回 `matched: true`
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: probes.example.yaml 使用跨平台示例
|
||||||
|
probes.example.yaml 中的 cmd 类型示例 SHALL 使用跨平台命令(如 `bun -e "..."`、`bun --version`),不使用 Unix 专属命令(如 `uname`、`ls /tmp`、`date`)。
|
||||||
|
|
||||||
|
#### Scenario: 示例命令跨平台可执行
|
||||||
|
- **WHEN** 用户在 Windows、macOS 或 Linux 上直接使用 probes.example.yaml 中的 cmd 示例
|
||||||
|
- **THEN** 所有 cmd 示例 SHALL 能正常执行,不依赖平台特定命令
|
||||||
27
openspec/changes/cmd-checker-enhancement/tasks.md
Normal file
27
openspec/changes/cmd-checker-enhancement/tasks.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
## 1. 源码目录重命名
|
||||||
|
|
||||||
|
- [ ] 1.1 重命名 `src/server/checker/runner/command/` → `src/server/checker/runner/cmd/`,更新目录内所有文件的 type/configKey 字面量为 `"cmd"`
|
||||||
|
- [ ] 1.2 重命名 `tests/server/checker/runner/command/` → `tests/server/checker/runner/cmd/`
|
||||||
|
- [ ] 1.3 更新所有 import 路径中的 `runner/command` → `runner/cmd`(包括 runner/index.ts 等)
|
||||||
|
|
||||||
|
## 2. 类型与配置重命名
|
||||||
|
|
||||||
|
- [ ] 2.1 更新 `src/server/checker/runner/cmd/execute.ts` 中 `type = "cmd"`、`configKey = "cmd"`、`context.defaults["cmd"]`、所有 `t.command.xxx` → `t.cmd.xxx`
|
||||||
|
- [ ] 2.2 更新 `src/server/checker/runner/cmd/types.ts` 中 `ResolvedCommandTarget.command` 属性名改为 `cmd`、`type: "command"` 改为 `type: "cmd"`
|
||||||
|
- [ ] 2.3 更新 `src/server/checker/runner/cmd/validate.ts` 中所有 `"command"` → `"cmd"` 字面量
|
||||||
|
- [ ] 2.4 更新 `src/server/checker/runner/cmd/schema.ts` 中 TypeBox 契约的分组名(如有 `"command"` 字面量)
|
||||||
|
- [ ] 2.5 更新 `probes.example.yaml` 中 `type: command` → `type: cmd`、`command:` → `cmd:`,示例命令改为跨平台命令
|
||||||
|
- [ ] 2.6 更新 `tests/server/app.test.ts`、`tests/server/bootstrap.test.ts`、`tests/server/checker/config-loader.test.ts`、`tests/server/checker/engine.test.ts` 中所有 `"command"` 字面量为 `"cmd"`
|
||||||
|
- [ ] 2.7 重新生成 `probe-config.schema.json`(执行 schema 生成脚本或手动更新)
|
||||||
|
|
||||||
|
## 3. 跨平台测试改造
|
||||||
|
|
||||||
|
- [ ] 3.1 更新 `tests/server/checker/runner/cmd/runner.test.ts` 中所有系统命令为 `bun -e "..."` 形式
|
||||||
|
- [ ] 3.2 更新 `tests/server/checker/runner/cmd/expect.test.ts` 中所有系统命令为 `bun -e "..."` 形式
|
||||||
|
|
||||||
|
## 4. Spec 文档与质量保障
|
||||||
|
|
||||||
|
- [ ] 4.1 重命名 `openspec/specs/command-checker/` → `openspec/specs/cmd-checker/`,更新 spec 内容中的 `command` → `cmd`
|
||||||
|
- [ ] 4.2 执行完整测试套件 `bun test`,确保所有测试通过
|
||||||
|
- [ ] 4.3 执行类型检查 `bunx tsc --noEmit`,确保无类型错误
|
||||||
|
- [ ] 4.4 更新 README.md 中涉及 command checker 的描述和配置示例(包括 defaults.command 段、type 枚举、配置字段说明)
|
||||||
147
openspec/changes/enhance-frontend-metrics/design.md
Normal file
147
openspec/changes/enhance-frontend-metrics/design.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
DiAL 是一个基于 Bun 的全栈拨测监控系统,当前前端统计指标体系存在以下问题:
|
||||||
|
|
||||||
|
1. **计算逻辑缺陷**:可用率基于全量历史数据计算(`store.ts:getAllTargetStats` 无 WHERE 时间条件),随运行时间增长近期变化被稀释;`computeTrendStats` 从已截断的百分比反推整数有累积精度损失;`lastCheckTime` 已返回但前端未展示
|
||||||
|
2. **指标维度单一**:Summary 仅 3 个计数卡片;Drawer 统计区 4 个指标(总检查/正常/异常/可用率)本质是同一维度的冗余表达
|
||||||
|
3. **缺少关键运维指标**:无 P95 延迟、无 MTTR、无故障分析、无连续状态信息
|
||||||
|
|
||||||
|
当前技术栈:后端 Bun + SQLite(bun:sqlite),前端 React + TDesign + recharts + TanStack Query。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- 修复可用率时间窗口、趋势数据精度损失、lastCheckTime 未展示三个计算逻辑问题
|
||||||
|
- Summary 增加「24h 异常事件数」卡片
|
||||||
|
- 表格增加「连续状态」列(Tag 样式,按次数)
|
||||||
|
- Drawer 统计区重构为 2×4 多维度布局(可用率/平均延迟/P95/检查总数 + MTTR/最长故障/故障次数/连续正常)
|
||||||
|
- 趋势图增加延迟范围面积(min/max),去掉可用率线改为异常时刻红色标记点
|
||||||
|
- 新增 `/api/targets/:id/stats` 端点,职责清晰(单目标非时序聚合指标)
|
||||||
|
- Drawer 统计区支持时间窗口切换(24h/7d/30d),联动统计+趋势
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不做整体可用率(不同分组不同目的的 target 算到一起无意义)
|
||||||
|
- 不做延迟 sparkline(表格已有状态条,信息密度够了)
|
||||||
|
- 不做趋势对比(vs 上周)
|
||||||
|
- 不做连续状态按时长展示(不同间隔的目标无法统一)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1:P95 在应用层计算,不在 SQL 层
|
||||||
|
|
||||||
|
**选择**:新增 `getTargetDurations(targetId, from, to)` 方法,一次性取出时间窗口内所有成功检查的 `duration_ms`,在 TypeScript 层排序取 P95/P99。
|
||||||
|
|
||||||
|
**理由**:SQLite 无原生 PERCENTILE 函数,用子查询模拟的 SQL 复杂且性能不可控。应用层排序对于单目标时间窗口内的数据量(24h × 每分钟 1 次 = 1440 条)完全可接受。
|
||||||
|
|
||||||
|
**替代方案**:SQLite 扩展函数 / 窗口函数模拟 — 复杂度高,可移植性差。
|
||||||
|
|
||||||
|
**命名**:方法名统一为 `getTargetDurations`(非 `getTargetPercentiles`),因为该方法职责是取原始数据,百分位计算在调用方完成。
|
||||||
|
|
||||||
|
### Decision 2:新增独立 `/api/targets/:id/stats` 端点
|
||||||
|
|
||||||
|
**选择**:创建新端点而非扩展现有 `/api/targets/:id/trend`。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- `/trend` 的职责是时序聚合数据(按小时分组),返回数组
|
||||||
|
- `/stats` 的职责是非时序聚合指标(P95、MTTR、故障分析),返回单个对象
|
||||||
|
- 两者语义清晰,避免一个大而全的端点
|
||||||
|
- `/stats` 只在 Drawer 打开时请求,不影响列表页性能
|
||||||
|
|
||||||
|
**替代方案**:扩展 `/trend` 在响应中附加 summary 字段 — 混淆了时序和聚合两种数据语义。
|
||||||
|
|
||||||
|
### Decision 3:异常事件数按「状态翻转」计数
|
||||||
|
|
||||||
|
**选择**:统计 `matched` 从 1→0 的转换次数(跨所有目标),而非每次 `matched=0` 的检查次数。
|
||||||
|
|
||||||
|
**理由**:一个目标连续异常 10 次只算 1 次事件,反映的是「发生了几次故障」而非「有多少次检查失败」。后者已经在可用率中体现。
|
||||||
|
|
||||||
|
**实现**:SQL 使用 LAG 窗口函数检测前后状态变化:
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT matched, LAG(matched) OVER (PARTITION BY target_id ORDER BY timestamp) as prev
|
||||||
|
FROM check_results WHERE timestamp >= ?
|
||||||
|
) WHERE matched = 0 AND (prev = 1 OR prev IS NULL)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decision 4:连续状态从 recentSamples 前端计算
|
||||||
|
|
||||||
|
**选择**:不新增 API,从已有的 `recentSamples`(30 条)在前端计算连续状态次数。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- `recentSamples` 已经按时间倒序返回,遍历到第一个状态不同的即可
|
||||||
|
- 无需额外网络请求
|
||||||
|
- 30 条样本对于连续状态计数足够(超过 30 次连续正常/异常的场景下,显示 "30+" 即可)
|
||||||
|
|
||||||
|
### Decision 5:趋势图去掉可用率线,改为异常标记点
|
||||||
|
|
||||||
|
**选择**:移除 availability 折线和右侧 Y 轴(%),改为单 Y 轴(ms)。在 avgDurationMs 线上,对 availability < 100 的时间点渲染红色 dot 标记异常。
|
||||||
|
|
||||||
|
**理由**:可用率通常是 100% 或接近 100%,作为连续曲线信息量极低(大部分时间是一条直线)。改为离散标记点后,异常时刻一目了然,且不占用 Y 轴空间。
|
||||||
|
|
||||||
|
**实现**:使用 recharts `<Line>` 的 `dot` 回调函数,对 `availability < 100` 的点渲染红色圆点(`fill: var(--td-error-color)`),其余点不渲染 dot。移除右侧 Y 轴和 availability Line 组件。
|
||||||
|
|
||||||
|
### Decision 6:时间窗口切换联动机制
|
||||||
|
|
||||||
|
**选择**:Drawer 中的时间窗口切换同时影响统计区和趋势图,stats 和 trend 同时刷新。
|
||||||
|
|
||||||
|
**实现**:
|
||||||
|
- stats 请求直接复用 Drawer 现有的 `timeFrom`/`timeTo` 状态,不引入额外时间状态
|
||||||
|
- 统计区数据来自 `/api/targets/:id/stats?from=&to=`
|
||||||
|
- 趋势图数据来自 `/api/targets/:id/trend?from=&to=`
|
||||||
|
- 切换快捷按钮(1h/6h/24h/7d)时,`timeFrom`/`timeTo` 更新,stats 和 trend 的 queryKey 变化触发同时刷新
|
||||||
|
- 默认选中 24h
|
||||||
|
- 表格的可用率固定 24h 窗口:前端 `useTargets` 请求 `/api/targets?window=24h`,后端解析 `window` 查询参数并转换为时间范围传递给 `getAllTargetStats(from, to)`,列标题改为"可用率(24h)"
|
||||||
|
|
||||||
|
### Decision 7:Drawer 统计区 2×4 布局
|
||||||
|
|
||||||
|
**选择**:统计区和可靠性区合并为一个 2 行 × 4 列的 Statistic 网格。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────┬────────────┬────────────┬────────────┐
|
||||||
|
│ 可用率 │ 平均延迟 │ P95延迟 │ 检查总数 │
|
||||||
|
├────────────┼────────────┼────────────┼────────────┤
|
||||||
|
│ MTTR │ 最长故障 │ 故障次数 │ 连续正常 │
|
||||||
|
└────────────┴────────────┴────────────┴────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:原来的「总检查/正常/异常/可用率」4 指标信息冗余,正常/异常计数已在环形图中展示。重构后每个格子都是独立维度,信息密度大幅提升。
|
||||||
|
|
||||||
|
### Decision 8:TrendPoint 增加 min/max 延迟字段
|
||||||
|
|
||||||
|
**选择**:在 SQL 聚合层直接计算 `MIN(duration_ms)` 和 `MAX(duration_ms)`,零额外成本。
|
||||||
|
|
||||||
|
**实现**:趋势图使用 recharts `<Area>` 组件渲染 min-max 范围(半透明品牌色填充),叠加 avg 实线。
|
||||||
|
|
||||||
|
### Decision 9:Summary lastCheckTime 展示为相对时间
|
||||||
|
|
||||||
|
**选择**:在 Summary 区域底部展示 "最后更新: X秒前" 文本,前端每秒更新。
|
||||||
|
|
||||||
|
**实现**:使用 `useState` + `setInterval` 每秒计算相对时间差。超过 60 秒时文字变为警告色(--td-warning-color),提示数据可能不新鲜。
|
||||||
|
|
||||||
|
### Decision 10:StatusDonut 数据来源改为 statsData
|
||||||
|
|
||||||
|
**选择**:StatusDonut 的 `up`/`down` 改为使用 `statsData.upChecks` / `statsData.downChecks`,不再从 trendData 反推。
|
||||||
|
|
||||||
|
**理由**:statsData 的 upChecks 是精确值(直接从 SQL COUNT 返回),与统计区的"检查总数"一致,消除了之前从百分比反推的精度损失。
|
||||||
|
|
||||||
|
**影响**:`computeTrendStats` 工具函数不再有调用方,直接删除。
|
||||||
|
|
||||||
|
### Decision 11:MTTR 窗口边界截断处理
|
||||||
|
|
||||||
|
**选择**:如果时间窗口内第一条记录即为 matched=0(故障跨越了 from 边界),该故障段不计入 MTTR 平均值,但计入 incidentCount。
|
||||||
|
|
||||||
|
**理由**:无法确定故障的真实开始时间,计入 MTTR 会低估实际恢复时间。incidentCount 计数是因为用户确实在窗口内经历了这次故障。
|
||||||
|
|
||||||
|
### Decision 12:getIncidents24h 作为独立方法
|
||||||
|
|
||||||
|
**选择**:`getIncidents24h()` 是 ProbeStore 的独立方法(单条 SQL),在 `handleSummary` 路由中调用并附加到响应。
|
||||||
|
|
||||||
|
**理由**:职责分离,getSummary() 保持原有的目标状态快照逻辑,incidents24h 是独立的时序分析查询。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[P95 数据量]** 30d 窗口下单目标可能有 ~43200 条记录需要排序 → 对于内存排序仍然可接受(<1MB),但如果未来数据量增长可考虑近似算法
|
||||||
|
- **[异常事件计数的 LAG 查询]** 窗口函数在大数据量下可能较慢 → 24h 窗口内数据量有限(所有目标 × 24h ÷ 间隔),可接受;如果性能不佳可改为应用层遍历
|
||||||
|
- **[前端连续状态上限 30]** recentSamples 固定 30 条,连续状态超过 30 次时显示 "30+" → 对于运维场景足够,真正需要精确值时可查看 Drawer 详情
|
||||||
|
- **[趋势图去掉可用率线]** 用户可能习惯看可用率曲线 → 异常标记点提供了等价信息且更直观,环形图仍展示可用率分布
|
||||||
|
- **[LAG 窗口边界误差]** 使用 LAG 窗口函数检测状态翻转时,若故障跨越时间窗口 from 边界(窗口内第一条即为 matched=0),会被计为一次新事件,实际可能是窗口外已开始的故障延续 → 对于 24h 窗口内的事件计数,该误差可接受且难以避免(需要额外查询窗口外数据才能消除)
|
||||||
33
openspec/changes/enhance-frontend-metrics/proposal.md
Normal file
33
openspec/changes/enhance-frontend-metrics/proposal.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
当前前端统计指标存在三个层面的问题:(1)计算逻辑缺陷——可用率无时间窗口导致历史数据稀释近期变化、`computeTrendStats` 从百分比反推整数有精度损失、`lastCheckTime` 返回但未展示;(2)指标维度单一——Summary 只有计数、Drawer 统计区 4 个指标本质是同一维度的重复表达、表格缺少连续状态等关键运维信息;(3)缺少性能和可靠性指标——无 P95 延迟、无 MTTR、无故障分析。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **计算逻辑修复**:可用率查询增加时间窗口参数(默认 24h);Trend API 直接返回 `upChecks` 消除前端反推精度损失;Summary 展示 `lastCheckTime` 相对时间
|
||||||
|
- **Summary 增强**:新增第 4 张卡片「24h 异常事件数」(状态翻转计数)
|
||||||
|
- **表格增强**:新增「连续状态」列,Tag 样式展示连续正常/异常次数
|
||||||
|
- **Drawer 统计区重构**:从冗余的 4 指标改为多维度布局(可用率 / 平均延迟 / P95 延迟 / 检查总数),支持时间窗口切换(24h/7d/30d)联动
|
||||||
|
- **Drawer 可靠性区块**:新增 MTTR / 最长故障 / 故障次数 / 连续正常 4 个指标,与统计区合并为 2×4 布局
|
||||||
|
- **趋势图增强**:增加延迟范围面积图(min/max),去掉可用率线改为异常时刻红色标记点
|
||||||
|
- **新增 Stats API**:`GET /api/targets/:id/stats` 端点,返回 P95(应用层排序计算)、MTTR、故障分析等深度统计
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `target-stats-api`: 单目标深度统计 API 端点,提供 P95/P99 延迟、MTTR、故障分析等非时序聚合指标
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `probe-api`: Summary API 增加 `incidents24h` 字段;Targets API 可用率改为固定 24h 窗口;Trend API 增加 `upChecks`/`minDurationMs`/`maxDurationMs` 字段
|
||||||
|
- `probe-data-store`: `getAllTargetStats`/`getTargetStats` 增加时间窗口参数;`getTrend` 增加 min/max 聚合;新增异常事件计数和检查序列查询方法
|
||||||
|
- `probe-dashboard`: Summary Cards 从 3 张扩展为 4 张,增加 `lastCheckTime` 展示
|
||||||
|
- `target-table`: 新增「连续状态」列(Tag 样式),可用率列标题改为"可用率(24h)"
|
||||||
|
- `target-detail-drawer`: 概览面板统计区重构为 2×4 多维度布局,趋势图改为延迟范围面积图+异常标记点,删除 computeTrendStats,StatusDonut 数据来源改为 statsData
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **后端**:`src/server/checker/store.ts` 增加带时间窗口的查询方法和新统计方法;新增 `src/server/routes/stats.ts` 路由
|
||||||
|
- **共享类型**:`src/shared/api.ts` 扩展 `SummaryResponse`、`TargetStatus`、`TrendPoint`,新增 `TargetStatsResponse` 类型
|
||||||
|
- **前端组件**:`SummaryCards`、`OverviewTab`、`TrendChart`、`target-table-columns` 均需修改;新增连续状态 Tag 组件
|
||||||
|
- **前端工具**:`utils/stats.ts` 的 `computeTrendStats` 删除(不再有调用方)
|
||||||
|
- **API 端点**:新增 `/api/targets/:id/stats`;修改 `/api/summary`、`/api/targets`、`/api/targets/:id/trend` 的响应结构
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 总览统计 API
|
||||||
|
系统 SHALL 提供 `GET /api/summary` 端点,返回所有目标的总体统计信息,包含异常事件计数。
|
||||||
|
|
||||||
|
#### Scenario: 获取总览统计
|
||||||
|
- **WHEN** 客户端请求 `GET /api/summary`
|
||||||
|
- **THEN** 系统 SHALL 返回 JSON 包含 total(总目标数)、up(正常数)、down(异常数)、lastCheckTime(最近一次检查时间)、incidents24h(过去 24 小时内的异常事件数,按状态翻转计数)
|
||||||
|
|
||||||
|
#### Scenario: 异常事件计数逻辑
|
||||||
|
- **WHEN** 计算 incidents24h
|
||||||
|
- **THEN** 系统 SHALL 统计过去 24 小时内所有目标中 matched 从 1 变为 0 的状态翻转次数;时间窗口起始即为 matched=0 且无前序记录的情况 SHALL 计为 1 次事件
|
||||||
|
|
||||||
|
### Requirement: 目标列表 API
|
||||||
|
系统 SHALL 提供 `GET /api/targets` 端点,返回所有 typed target 及其最新状态、分组信息和结构化采样数据,可用率基于 window 查询参数指定的时间窗口计算。
|
||||||
|
|
||||||
|
#### Scenario: 获取目标列表
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets?window=24h`
|
||||||
|
- **THEN** 系统 SHALL 解析 window 参数(支持格式如 "24h"、"7d"),将其转换为时间范围,返回 JSON 数组,每个元素的 stats.availability 和 stats.totalChecks SHALL 基于该时间窗口的数据计算
|
||||||
|
|
||||||
|
#### Scenario: window 参数缺失
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets` 未提供 window 参数
|
||||||
|
- **THEN** 系统 SHALL 默认使用 24h 时间窗口
|
||||||
|
|
||||||
|
#### Scenario: 目标无历史记录
|
||||||
|
- **WHEN** 某目标尚未执行过任何拨测
|
||||||
|
- **THEN** 其 latestCheck 为 null,recentSamples 为空数组,stats.availability 为 0
|
||||||
|
|
||||||
|
### Requirement: 趋势 API 支持时间范围
|
||||||
|
系统 SHALL 提供 `GET /api/targets/:id/trend` 端点,返回包含延迟范围和正常检查数的趋势数据。
|
||||||
|
|
||||||
|
#### Scenario: 指定时间范围查询趋势
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets/1/trend?from=ISO&to=ISO`
|
||||||
|
- **THEN** 系统 SHALL 返回指定时间范围内按小时分组的聚合数据,每个数据点包含 hour、avgDurationMs、minDurationMs、maxDurationMs、availability、totalChecks、upChecks
|
||||||
|
|
||||||
|
#### Scenario: from 或 to 参数缺失
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
|
||||||
|
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||||
|
|
||||||
|
### Requirement: 新增共享类型
|
||||||
|
系统 SHALL 在 `src/shared/api.ts` 中定义扩展后的 `SummaryResponse`、`TrendPoint` 和 `TargetStats` 类型。
|
||||||
|
|
||||||
|
#### Scenario: SummaryResponse 类型
|
||||||
|
- **WHEN** 前后端共享 `SummaryResponse` 类型
|
||||||
|
- **THEN** 该类型 SHALL 包含 `total: number`、`up: number`、`down: number`、`lastCheckTime: string | null`、`incidents24h: number` 字段
|
||||||
|
|
||||||
|
#### Scenario: TrendPoint 类型
|
||||||
|
- **WHEN** 前后端共享 `TrendPoint` 类型
|
||||||
|
- **THEN** 该类型 SHALL 包含 `hour: string`、`avgDurationMs: number | null`、`minDurationMs: number | null`、`maxDurationMs: number | null`、`availability: number`、`totalChecks: number`、`upChecks: number` 字段
|
||||||
|
|
||||||
|
#### Scenario: TargetStats 类型
|
||||||
|
- **WHEN** 前后端共享 `TargetStats` 类型
|
||||||
|
- **THEN** 该类型 SHALL 包含 `availability: number`、`totalChecks: number` 字段(语义变更为基于时间窗口计算)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 总览统计卡片
|
||||||
|
Dashboard SHALL 在页面顶部使用 TDesign Statistic 组件展示总览统计,包含总目标数、正常数、异常数和 24h 异常事件数,并展示数据新鲜度。
|
||||||
|
|
||||||
|
#### Scenario: 展示统计卡片
|
||||||
|
- **WHEN** 用户打开 Dashboard 页面
|
||||||
|
- **THEN** 页面顶部 SHALL 使用 TDesign Row/Col 布局展示 4 个 TDesign Card + Statistic 组合:全部目标数(color=blue)、正常目标数(color=green)、异常目标数(color=red)、24h 异常事件数(color=orange)
|
||||||
|
|
||||||
|
#### Scenario: 展示数据新鲜度
|
||||||
|
- **WHEN** Summary 数据包含 lastCheckTime
|
||||||
|
- **THEN** 统计卡片行底部 SHALL 展示相对时间文本(如"最后更新: 3秒前"),使用 TDesign Typography.Text(theme="secondary")
|
||||||
|
|
||||||
|
#### Scenario: 数据新鲜度警告
|
||||||
|
- **WHEN** lastCheckTime 距当前时间超过 60 秒
|
||||||
|
- **THEN** 相对时间文本 SHALL 使用警告色(--td-warning-color)
|
||||||
|
|
||||||
|
#### Scenario: 统计数据自动刷新
|
||||||
|
- **WHEN** 页面处于打开状态
|
||||||
|
- **THEN** 统计卡片 SHALL 通过 TanStack Query 的 refetchInterval=8000 自动刷新数据
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 聚合查询支持
|
||||||
|
数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均耗时、延迟范围等统计指标。所有聚合查询 SHALL 支持时间窗口参数。
|
||||||
|
|
||||||
|
#### Scenario: 计算目标可用率(带时间窗口)
|
||||||
|
- **WHEN** 查询某目标在指定时间范围内的可用率
|
||||||
|
- **THEN** 系统 SHALL 返回该时间范围内 matched=1 的记录数占总记录数的百分比
|
||||||
|
|
||||||
|
#### Scenario: 计算目标平均耗时
|
||||||
|
- **WHEN** 查询某目标在指定时间范围内的平均耗时
|
||||||
|
- **THEN** 系统 SHALL 返回 duration_ms 的平均值(仅计算 matched=1 的记录)
|
||||||
|
|
||||||
|
#### Scenario: 按小时聚合趋势数据(含延迟范围)
|
||||||
|
- **WHEN** 查询某目标在指定时间范围内的趋势数据
|
||||||
|
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的 avgDurationMs、minDurationMs(成功检查的最小延迟)、maxDurationMs(成功检查的最大延迟)、availability、totalChecks、upChecks
|
||||||
|
|
||||||
|
#### Scenario: UP/DOWN 判定
|
||||||
|
- **THEN** 系统 SHALL 基于 latestCheck.matched 判定目标 UP 或 DOWN:matched=true 为 UP,matched=false 为 DOWN
|
||||||
|
|
||||||
|
### Requirement: 目标统计查询支持时间窗口
|
||||||
|
`getAllTargetStats` 和 `getTargetStats` SHALL 接受可选的时间窗口参数,限制聚合的数据范围。
|
||||||
|
|
||||||
|
#### Scenario: 带时间窗口的批量统计
|
||||||
|
- **WHEN** 调用 `getAllTargetStats(from, to)`
|
||||||
|
- **THEN** 系统 SHALL 仅聚合 timestamp 在 from 到 to 范围内的 check_results 记录
|
||||||
|
|
||||||
|
#### Scenario: 不传时间窗口
|
||||||
|
- **WHEN** 调用 `getAllTargetStats()` 不传时间参数
|
||||||
|
- **THEN** 系统 SHALL 默认使用过去 24 小时作为时间窗口
|
||||||
|
|
||||||
|
#### Scenario: 带时间窗口的单目标统计
|
||||||
|
- **WHEN** 调用 `getTargetStats(targetId, from, to)`
|
||||||
|
- **THEN** 系统 SHALL 仅聚合指定时间范围内的记录
|
||||||
|
|
||||||
|
### Requirement: 趋势数据时间范围查询
|
||||||
|
系统 SHALL 支持按任意时间范围查询趋势聚合数据,返回包含延迟范围和正常检查数的完整聚合。
|
||||||
|
|
||||||
|
#### Scenario: 按时间范围查询趋势(含延迟范围)
|
||||||
|
- **WHEN** 查询指定 target 在 from 到 to 时间范围内的趋势数据
|
||||||
|
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgDurationMs、minDurationMs、maxDurationMs、availability、totalChecks、upChecks
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 异常事件计数查询
|
||||||
|
ProbeStore SHALL 提供 `getIncidents24h()` 方法,统计过去 24 小时内所有目标的异常事件数。
|
||||||
|
|
||||||
|
#### Scenario: 计算异常事件数
|
||||||
|
- **WHEN** 调用 `getIncidents24h()`
|
||||||
|
- **THEN** 系统 SHALL 统计过去 24 小时内所有目标中 matched 从 1 变为 0 的状态翻转次数
|
||||||
|
|
||||||
|
#### Scenario: 窗口起始即为故障
|
||||||
|
- **WHEN** 某目标在 24 小时窗口内第一条记录为 matched=0 且窗口前无记录
|
||||||
|
- **THEN** 该故障 SHALL 计为 1 次事件
|
||||||
|
|
||||||
|
#### Scenario: 连续异常只计一次
|
||||||
|
- **WHEN** 某目标连续 10 次 matched=0
|
||||||
|
- **THEN** 该连续异常段 SHALL 仅计为 1 次事件
|
||||||
|
|
||||||
|
### Requirement: 目标延迟百分位查询
|
||||||
|
ProbeStore SHALL 提供 `getTargetDurations(targetId, from, to)` 方法,返回时间窗口内所有成功检查的 duration_ms 数组。
|
||||||
|
|
||||||
|
#### Scenario: 获取延迟数据
|
||||||
|
- **WHEN** 调用 `getTargetDurations(targetId, from, to)`
|
||||||
|
- **THEN** 系统 SHALL 返回该目标在时间范围内所有 matched=1 的 duration_ms 值数组,按升序排列
|
||||||
|
|
||||||
|
#### Scenario: 无成功检查
|
||||||
|
- **WHEN** 时间窗口内无 matched=1 的记录
|
||||||
|
- **THEN** 系统 SHALL 返回空数组
|
||||||
|
|
||||||
|
### Requirement: 目标故障段查询
|
||||||
|
ProbeStore SHALL 提供 `getCheckSequence(targetId, from, to)` 方法,返回时间窗口内的检查序列用于故障分析。
|
||||||
|
|
||||||
|
#### Scenario: 获取检查序列
|
||||||
|
- **WHEN** 调用 `getCheckSequence(targetId, from, to)`
|
||||||
|
- **THEN** 系统 SHALL 返回该目标在时间范围内所有检查记录的 `{ timestamp: string, matched: number }` 数组,按 timestamp 升序排列
|
||||||
|
|
||||||
|
#### Scenario: 无检查记录
|
||||||
|
- **WHEN** 时间窗口内无记录
|
||||||
|
- **THEN** 系统 SHALL 返回空数组
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 概览面板组件化
|
||||||
|
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,展示多维度统计、趋势图、状态分布和基本信息。
|
||||||
|
|
||||||
|
#### Scenario: OverviewTab 组件职责
|
||||||
|
- **WHEN** 概览 Tab 渲染
|
||||||
|
- **THEN** `OverviewTab` 组件 SHALL 负责多维度统计卡片(2×4 布局)、趋势图(延迟范围面积图+异常标记点)、状态分布环形图和基本信息的渲染
|
||||||
|
|
||||||
|
#### Scenario: 统计计算不再使用 computeTrendStats
|
||||||
|
- **WHEN** OverviewTab 需要 totalChecks、upChecks、downChecks
|
||||||
|
- **THEN** SHALL 直接使用 statsData 中的 totalChecks、upChecks、downChecks 字段,`computeTrendStats` 工具函数 SHALL 被删除
|
||||||
|
|
||||||
|
#### Scenario: OverviewTab props
|
||||||
|
- **WHEN** OverviewTab 渲染
|
||||||
|
- **THEN** 组件 SHALL 接收 `target: TargetStatus`、`trendData: TrendPoint[]`、`trendLoading: boolean`、`statsData: TargetStatsResponse | null`、`statsLoading: boolean` 作为 props
|
||||||
|
|
||||||
|
### Requirement: 概览面板
|
||||||
|
概览 Tab SHALL 按区域展示多维度统计、趋势图、状态分布和基本信息。
|
||||||
|
|
||||||
|
#### Scenario: 区域排列顺序
|
||||||
|
- **WHEN** 概览面板渲染
|
||||||
|
- **THEN** 面板 SHALL 按以下顺序展示区域:统计 → 趋势 → 状态分布 → 基本信息,每个区域前 SHALL 显示 TDesign Divider(align="left")作为小标题
|
||||||
|
|
||||||
|
#### Scenario: 统计区多维度布局
|
||||||
|
- **WHEN** 概览面板渲染
|
||||||
|
- **THEN** 面板 SHALL 在"统计"区域使用 2 行 × 4 列的 TDesign Row/Col + Statistic 布局:第一行为可用率(suffix="%")、平均延迟(suffix="ms")、P95 延迟(suffix="ms")、检查总数;第二行为 MTTR(动态单位)、最长故障(动态单位)、故障次数(suffix="次")、连续正常(suffix="次",固定标题"连续正常",当目标当前处于异常状态时值为 0)
|
||||||
|
|
||||||
|
#### Scenario: MTTR 和最长故障动态单位
|
||||||
|
- **WHEN** MTTR 或最长故障值小于 60000ms
|
||||||
|
- **THEN** SHALL 以秒为单位展示(suffix="秒")
|
||||||
|
- **WHEN** 值大于等于 60000ms 且小于 3600000ms
|
||||||
|
- **THEN** SHALL 以分钟为单位展示(suffix="分钟")
|
||||||
|
- **WHEN** 值大于等于 3600000ms
|
||||||
|
- **THEN** SHALL 以小时为单位展示(suffix="小时")
|
||||||
|
|
||||||
|
#### Scenario: 统计区数据来源
|
||||||
|
- **WHEN** 统计区渲染
|
||||||
|
- **THEN** 第一行数据 SHALL 来自 statsData(TargetStatsResponse),第二行数据同样来自 statsData
|
||||||
|
|
||||||
|
#### Scenario: 统计区加载状态
|
||||||
|
- **WHEN** statsData 正在加载
|
||||||
|
- **THEN** 统计区 SHALL 显示 TDesign Skeleton 加载占位
|
||||||
|
|
||||||
|
#### Scenario: 趋势图延迟范围面积
|
||||||
|
- **WHEN** 概览面板渲染且趋势数据可用
|
||||||
|
- **THEN** 趋势图 SHALL 使用 recharts Area 组件渲染 minDurationMs 到 maxDurationMs 的延迟范围(半透明品牌色填充),叠加 avgDurationMs 实线
|
||||||
|
|
||||||
|
#### Scenario: 趋势图异常标记点
|
||||||
|
- **WHEN** 趋势数据中某小时的 availability < 100
|
||||||
|
- **THEN** 趋势图 SHALL 在 avgDurationMs 线上该时间点渲染红色圆点(fill: var(--td-error-color)),使用 recharts Line 的 dot 回调函数实现;图表 SHALL 仅保留左侧 Y 轴(ms),移除右侧 Y 轴(%)和 availability 折线
|
||||||
|
|
||||||
|
#### Scenario: 趋势数据加载中
|
||||||
|
- **WHEN** 概览面板渲染且趋势数据正在加载
|
||||||
|
- **THEN** "趋势"区域 SHALL 显示 TDesign Skeleton 加载占位
|
||||||
|
|
||||||
|
#### Scenario: 状态分布环形图
|
||||||
|
- **WHEN** 概览面板渲染且 statsData 可用
|
||||||
|
- **THEN** 面板 SHALL 在"状态分布"区域展示 recharts 环形图(StatusDonut),使用 statsData.upChecks 和 statsData.downChecks 作为数据源,外圈显示 UP/DOWN 比例,中间显示可用率百分比
|
||||||
|
|
||||||
|
#### Scenario: 状态分布加载状态
|
||||||
|
- **WHEN** statsData 正在加载
|
||||||
|
- **THEN** 状态分布区域 SHALL 显示 TDesign Skeleton 加载占位
|
||||||
|
|
||||||
|
#### Scenario: 元信息展示
|
||||||
|
- **WHEN** 概览面板渲染
|
||||||
|
- **THEN** 面板 SHALL 在"基本信息"区域使用 TDesign Descriptions 组件展示目标元信息:目标地址、检查间隔、最新检查时间、状态详情
|
||||||
|
|
||||||
|
### Requirement: 时间范围选择器
|
||||||
|
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。时间选择器 SHALL 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。
|
||||||
|
|
||||||
|
#### Scenario: 快捷时间按钮
|
||||||
|
- **WHEN** Drawer 渲染
|
||||||
|
- **THEN** 时间选择区第一行 SHALL 显示 TDesign RadioGroup(variant=default-filled)快捷按钮:1小时、6小时、24小时、7天
|
||||||
|
|
||||||
|
#### Scenario: 点击快捷按钮
|
||||||
|
- **WHEN** 用户点击快捷按钮(如 "24小时")
|
||||||
|
- **THEN** 系统 SHALL 自动设置对应的起止时间,DateRangePicker 显示对应的时间范围,该按钮高亮
|
||||||
|
|
||||||
|
#### Scenario: 快捷按钮联动统计区
|
||||||
|
- **WHEN** 用户点击 1小时/6小时/24小时/7天 快捷按钮
|
||||||
|
- **THEN** 统计区和趋势图 SHALL 使用对应的时间窗口重新请求 `/api/targets/:id/stats` 和 `/api/targets/:id/trend` 数据
|
||||||
|
|
||||||
|
#### Scenario: 自定义日期时间范围
|
||||||
|
- **WHEN** 用户通过 TDesign DateRangePicker(mode=date, enableTimePicker, format="YYYY-MM-DD HH:mm")修改时间范围
|
||||||
|
- **THEN** 快捷按钮 SHALL 取消高亮,系统重新请求对应时间范围的数据
|
||||||
|
|
||||||
|
#### Scenario: 默认时间范围
|
||||||
|
- **WHEN** Drawer 打开
|
||||||
|
- **THEN** 时间选择器 SHALL 默认选中 "24小时" 快捷按钮
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Stats 数据查询 Hook
|
||||||
|
系统 SHALL 提供 `useTargetStats` hook 查询单目标深度统计数据。
|
||||||
|
|
||||||
|
#### Scenario: stats queryKey
|
||||||
|
- **WHEN** 查询某目标的统计数据
|
||||||
|
- **THEN** queryKey SHALL 为 ["stats", targetId, from, to]
|
||||||
|
|
||||||
|
#### Scenario: stats 条件查询
|
||||||
|
- **WHEN** 用户未选中任何目标
|
||||||
|
- **THEN** stats 的 useQuery SHALL enabled=false,不发起请求
|
||||||
|
|
||||||
|
#### Scenario: stats 数据返回
|
||||||
|
- **WHEN** stats 查询成功
|
||||||
|
- **THEN** hook SHALL 返回 `TargetStatsResponse` 类型数据
|
||||||
|
|
||||||
|
#### Scenario: 时间范围变化时重新请求
|
||||||
|
- **WHEN** 用户更改时间范围
|
||||||
|
- **THEN** stats 的 useQuery SHALL 因 queryKey 变化自动重新请求
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 单目标深度统计 API
|
||||||
|
系统 SHALL 提供 `GET /api/targets/:id/stats` 端点,返回单个目标在指定时间窗口内的非时序聚合统计指标。
|
||||||
|
|
||||||
|
#### Scenario: 获取目标统计
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets/1/stats?from=ISO&to=ISO`
|
||||||
|
- **THEN** 系统 SHALL 返回 JSON 对象包含 p95DurationMs、p99DurationMs、avgDurationMs、mttr、longestOutage、incidentCount、currentStreak、totalChecks、upChecks、downChecks、availability
|
||||||
|
|
||||||
|
#### Scenario: from 或 to 参数缺失
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets/1/stats` 未提供 from 或 to 参数
|
||||||
|
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||||
|
|
||||||
|
#### Scenario: 目标不存在
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets/999/stats`
|
||||||
|
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||||
|
|
||||||
|
#### Scenario: 无效的目标 ID
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets/abc/stats`
|
||||||
|
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||||
|
|
||||||
|
### Requirement: P95/P99 延迟计算
|
||||||
|
系统 SHALL 在应用层计算 P95 和 P99 延迟百分位数。
|
||||||
|
|
||||||
|
#### Scenario: 正常计算 P95
|
||||||
|
- **WHEN** 时间窗口内存在成功检查记录(matched=1)
|
||||||
|
- **THEN** 系统 SHALL 取出所有成功检查的 duration_ms,在应用层排序后取第 95 百分位值返回为 p95DurationMs
|
||||||
|
|
||||||
|
#### Scenario: 正常计算 P99
|
||||||
|
- **WHEN** 时间窗口内存在成功检查记录
|
||||||
|
- **THEN** 系统 SHALL 取第 99 百分位值返回为 p99DurationMs
|
||||||
|
|
||||||
|
#### Scenario: 无成功检查记录
|
||||||
|
- **WHEN** 时间窗口内无 matched=1 的记录
|
||||||
|
- **THEN** p95DurationMs 和 p99DurationMs SHALL 返回 null
|
||||||
|
|
||||||
|
#### Scenario: 百分位计算方法
|
||||||
|
- **WHEN** 计算第 N 百分位
|
||||||
|
- **THEN** 系统 SHALL 将 duration_ms 升序排列,取 index = ceil(count * N / 100) - 1 位置的值
|
||||||
|
|
||||||
|
### Requirement: MTTR 计算
|
||||||
|
系统 SHALL 计算平均恢复时间(Mean Time To Recovery)。
|
||||||
|
|
||||||
|
#### Scenario: 存在已恢复的故障段
|
||||||
|
- **WHEN** 时间窗口内存在至少一个已恢复的故障段(连续 matched=0 后跟 matched=1)
|
||||||
|
- **THEN** 系统 SHALL 计算所有已恢复故障段的平均持续时间(从首个 matched=0 的 timestamp 到恢复后首个 matched=1 的 timestamp 之差),返回为 mttr(毫秒)
|
||||||
|
|
||||||
|
#### Scenario: 无已恢复的故障段
|
||||||
|
- **WHEN** 时间窗口内无已恢复的故障段(全部正常,或当前仍在故障中且无历史恢复)
|
||||||
|
- **THEN** mttr SHALL 返回 null
|
||||||
|
|
||||||
|
#### Scenario: 当前正在故障中
|
||||||
|
- **WHEN** 时间窗口内最后一段故障尚未恢复
|
||||||
|
- **THEN** 该未恢复的故障段 SHALL 不计入 MTTR 平均值
|
||||||
|
|
||||||
|
#### Scenario: 窗口起始即为故障且后续恢复
|
||||||
|
- **WHEN** 时间窗口内第一条记录即为 matched=0(故障跨越了 from 边界),且该故障段在窗口内恢复
|
||||||
|
- **THEN** 该故障段 SHALL 不计入 MTTR 平均值(因无法确定真实故障开始时间),但 SHALL 计入 incidentCount
|
||||||
|
|
||||||
|
### Requirement: 最长故障时长
|
||||||
|
系统 SHALL 计算时间窗口内最长的单次故障持续时间。
|
||||||
|
|
||||||
|
#### Scenario: 存在故障段
|
||||||
|
- **WHEN** 时间窗口内存在故障段
|
||||||
|
- **THEN** 系统 SHALL 返回最长故障段的持续时间为 longestOutage(毫秒)
|
||||||
|
|
||||||
|
#### Scenario: 无故障
|
||||||
|
- **WHEN** 时间窗口内无 matched=0 的记录
|
||||||
|
- **THEN** longestOutage SHALL 返回 null
|
||||||
|
|
||||||
|
#### Scenario: 当前正在故障中
|
||||||
|
- **WHEN** 最后一段故障尚未恢复
|
||||||
|
- **THEN** 该故障段的持续时间 SHALL 计算为从故障开始到时间窗口 to 参数的时间差
|
||||||
|
|
||||||
|
### Requirement: 故障事件计数
|
||||||
|
系统 SHALL 计算时间窗口内的故障事件次数。
|
||||||
|
|
||||||
|
#### Scenario: 计算故障事件数
|
||||||
|
- **WHEN** 时间窗口内存在状态翻转(matched 从 1 变为 0)
|
||||||
|
- **THEN** 系统 SHALL 返回翻转次数为 incidentCount
|
||||||
|
|
||||||
|
#### Scenario: 无故障事件
|
||||||
|
- **WHEN** 时间窗口内所有检查均为 matched=1
|
||||||
|
- **THEN** incidentCount SHALL 返回 0
|
||||||
|
|
||||||
|
#### Scenario: 窗口起始即为故障
|
||||||
|
- **WHEN** 时间窗口内第一条记录即为 matched=0 且无前序记录可判断翻转
|
||||||
|
- **THEN** 该故障 SHALL 计为 1 次事件
|
||||||
|
|
||||||
|
### Requirement: 当前连续状态
|
||||||
|
系统 SHALL 返回目标当前的连续状态信息。
|
||||||
|
|
||||||
|
#### Scenario: 当前连续正常
|
||||||
|
- **WHEN** 目标最近的检查记录连续为 matched=1
|
||||||
|
- **THEN** currentStreak SHALL 返回 `{ up: true, count: N }`,N 为连续正常的检查次数
|
||||||
|
|
||||||
|
#### Scenario: 当前连续异常
|
||||||
|
- **WHEN** 目标最近的检查记录连续为 matched=0
|
||||||
|
- **THEN** currentStreak SHALL 返回 `{ up: false, count: N }`,N 为连续异常的检查次数
|
||||||
|
|
||||||
|
### Requirement: TargetStatsResponse 共享类型
|
||||||
|
系统 SHALL 在 `src/shared/api.ts` 中定义 `TargetStatsResponse` 类型。
|
||||||
|
|
||||||
|
#### Scenario: 类型定义
|
||||||
|
- **WHEN** 前后端引用 `TargetStatsResponse` 类型
|
||||||
|
- **THEN** 该类型 SHALL 包含 p95DurationMs(number | null)、p99DurationMs(number | null)、avgDurationMs(number | null)、mttr(number | null)、longestOutage(number | null)、incidentCount(number)、currentStreak({ up: boolean; count: number })、totalChecks(number)、upChecks(number)、downChecks(number)、availability(number)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 连续状态列
|
||||||
|
表格 SHALL 包含「连续状态」列,展示目标当前连续正常或异常的次数。
|
||||||
|
|
||||||
|
#### Scenario: 连续状态列渲染
|
||||||
|
- **WHEN** 表格渲染
|
||||||
|
- **THEN** 表格 SHALL 在「最近状态」列之后、「延迟」列之前展示「连续状态」列,标题为"连续",宽度 100px
|
||||||
|
|
||||||
|
#### Scenario: 连续正常展示
|
||||||
|
- **WHEN** 目标当前连续正常
|
||||||
|
- **THEN** 列 SHALL 使用 TDesign Tag 组件(theme=success, variant=light, size=small)展示 "▲ N次"
|
||||||
|
|
||||||
|
#### Scenario: 连续异常展示
|
||||||
|
- **WHEN** 目标当前连续异常
|
||||||
|
- **THEN** 列 SHALL 使用 TDesign Tag 组件(theme=danger, variant=light, size=small)展示 "▼ N次"
|
||||||
|
|
||||||
|
#### Scenario: 连续状态计算
|
||||||
|
- **WHEN** 计算连续状态
|
||||||
|
- **THEN** 系统 SHALL 从 recentSamples(按时间倒序)遍历,统计从最新记录开始连续相同状态的次数
|
||||||
|
|
||||||
|
#### Scenario: 超过样本上限
|
||||||
|
- **WHEN** 连续状态次数等于 recentSamples 长度(30)
|
||||||
|
- **THEN** 列 SHALL 展示 "▲ 30+" 或 "▼ 30+"
|
||||||
|
|
||||||
|
#### Scenario: 无样本数据
|
||||||
|
- **WHEN** 目标的 recentSamples 为空数组
|
||||||
|
- **THEN** 列 SHALL 展示 "-"
|
||||||
55
openspec/changes/enhance-frontend-metrics/tasks.md
Normal file
55
openspec/changes/enhance-frontend-metrics/tasks.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
## 1. 共享类型与数据层
|
||||||
|
|
||||||
|
- [ ] 1.1 扩展 `src/shared/api.ts`:SummaryResponse 增加 incidents24h;TrendPoint 增加 upChecks/minDurationMs/maxDurationMs;新增 TargetStatsResponse 类型
|
||||||
|
- [ ] 1.2 ProbeStore 修改 `getAllTargetStats(from?, to?)` 和 `getTargetStats(targetId, from?, to?)` 增加时间窗口参数,默认 24h
|
||||||
|
- [ ] 1.3 ProbeStore 修改 `getTrend` SQL 增加 MIN/MAX duration_ms 和 upChecks 聚合字段
|
||||||
|
- [ ] 1.4 ProbeStore 新增 `getIncidents24h()` 独立方法,使用 LAG 窗口函数统计所有目标的状态翻转次数
|
||||||
|
- [ ] 1.5 ProbeStore 新增 `getTargetDurations(targetId, from, to)` 方法,返回成功检查的 duration_ms 升序数组
|
||||||
|
- [ ] 1.6 ProbeStore 新增 `getCheckSequence(targetId, from, to)` 方法,返回检查序列用于故障分析
|
||||||
|
- [ ] 1.7 编写 ProbeStore 新增/修改方法的单元测试
|
||||||
|
|
||||||
|
## 2. 后端 API 路由
|
||||||
|
|
||||||
|
- [ ] 2.1 修改 `src/server/routes/summary.ts`:调用 store.getIncidents24h(),响应增加 incidents24h 字段
|
||||||
|
- [ ] 2.2 修改 `src/server/routes/targets.ts`:解析 `?window=24h` 查询参数,转换为时间范围传递给 getAllTargetStats(from, to),缺省默认 24h
|
||||||
|
- [ ] 2.3 修改 `src/server/routes/trend.ts`:响应增加 upChecks/minDurationMs/maxDurationMs 字段
|
||||||
|
- [ ] 2.4 新增 `src/server/routes/stats.ts`:实现 GET /api/targets/:id/stats?from=&to= 端点,应用层计算 P95/P99、MTTR、最长故障、故障次数、连续状态
|
||||||
|
- [ ] 2.5 在 `src/server/server.ts` 路由注册中挂载 stats 路由
|
||||||
|
- [ ] 2.6 编写 stats 路由的集成测试(含 P95 计算、MTTR 计算、窗口边界截断、无数据等边界情况)
|
||||||
|
- [ ] 2.7 编写 summary/targets/trend 路由修改的测试更新
|
||||||
|
|
||||||
|
## 3. 前端工具函数
|
||||||
|
|
||||||
|
- [ ] 3.1 删除 `src/web/utils/stats.ts` 中的 `computeTrendStats` 函数(不再有调用方)
|
||||||
|
- [ ] 3.2 新增连续状态计算工具函数 `getConsecutiveStatus(samples: RecentSample[]): { up: boolean; count: number }`
|
||||||
|
- [ ] 3.3 新增时间格式化工具函数:相对时间(X秒前/X分钟前)、动态单位(ms→秒/分钟/小时)
|
||||||
|
- [ ] 3.4 编写工具函数的单元测试
|
||||||
|
|
||||||
|
## 4. 前端数据层
|
||||||
|
|
||||||
|
- [ ] 4.1 修改 `src/web/hooks/use-queries.ts`:useTargets 请求改为 `/api/targets?window=24h`,后端解析 window 参数转换为时间范围
|
||||||
|
- [ ] 4.2 新增 useTargetStats hook(queryKey: ["stats", targetId, from, to],enabled 依赖 targetId 存在)
|
||||||
|
- [ ] 4.3 修改 `use-target-detail.ts`:集成 useTargetStats 调用,复用现有 timeFrom/timeTo 状态
|
||||||
|
|
||||||
|
## 5. 前端组件 — Summary Cards
|
||||||
|
|
||||||
|
- [ ] 5.1 修改 `SummaryCards.tsx`:从 3 列(span=4)扩展为 4 列(span=3),新增 24h 异常事件数卡片(color=orange)
|
||||||
|
- [ ] 5.2 在 SummaryCards 底部增加 lastCheckTime 相对时间展示(useState + setInterval 每秒更新),超过 60 秒变警告色
|
||||||
|
|
||||||
|
## 6. 前端组件 — Target Table
|
||||||
|
|
||||||
|
- [ ] 6.1 修改 `target-table-columns.tsx`:可用率列标题改为"可用率(24h)"
|
||||||
|
- [ ] 6.2 修改 `target-table-columns.tsx`:在「最近状态」列后新增「连续」列(width=100),使用 TDesign Tag(theme=success/danger, variant=light, size=small)渲染 "▲ N次" / "▼ N次"
|
||||||
|
|
||||||
|
## 7. 前端组件 — Drawer 概览
|
||||||
|
|
||||||
|
- [ ] 7.1 修改 `OverviewTab.tsx`:props 增加 statsData/statsLoading;删除 computeTrendStats 调用;统计区重构为 2×4 Statistic 布局,数据来自 statsData
|
||||||
|
- [ ] 7.2 修改 `OverviewTab.tsx`:StatusDonut 数据来源改为 statsData.upChecks / statsData.downChecks
|
||||||
|
- [ ] 7.3 修改 `TrendChart.tsx`:移除右侧 Y 轴和 availability Line;增加 Area 组件渲染 min/max 延迟范围(半透明品牌色填充);avgDurationMs Line 的 dot 回调对 availability < 100 的点渲染红色圆点
|
||||||
|
- [ ] 7.4 修改 `TargetDetailDrawer.tsx`:TIME_SHORTCUTS 保持 1h/6h/24h/7d 四个选项,默认选中 24h
|
||||||
|
- [ ] 7.5 修改 `TargetDetailDrawer.tsx`:集成 useTargetStats,传递 statsData/statsLoading 给 OverviewTab
|
||||||
|
|
||||||
|
## 8. 质量保障
|
||||||
|
|
||||||
|
- [ ] 8.1 运行完整测试套件,确保所有测试通过
|
||||||
|
- [ ] 8.2 运行 lint 和格式检查,修复所有问题
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
当前前端代码约 970 行,功能完整但存在以下架构问题:
|
|
||||||
|
|
||||||
1. **hook 职责过重**:`hooks/useTargetDetail.ts`(113 行)同时承载全局查询(`useSummary`、`useTargets`)、Drawer 状态管理、条件查询和通用 `fetchJson` 封装,文件名与实际职责不匹配
|
|
||||||
2. **组件体积膨胀**:`TargetDetailDrawer.tsx`(228 行)混合了时间选择逻辑、两个 Tab 的完整渲染、列定义和统计计算
|
|
||||||
3. **类型维护重复**:`target-type-display.ts` 和 `target-table-filters.ts` 各自硬编码 checker 类型列表,新增 checker 需改两处前端文件
|
|
||||||
4. **测试覆盖不足**:仅 `constants/` 下 4 个纯函数有测试,`utils/time.ts` 和组件内统计逻辑未覆盖
|
|
||||||
5. **小问题**:`StatusDonut` 用数组索引做 key、`StatusBar` 硬编码 30 格、`TrendChart` 冗余 loading prop
|
|
||||||
|
|
||||||
后端 `CheckerRegistry` 已有 `supportedTypes` 属性,可直接暴露给前端。项目未上线,不需要向前兼容。
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
|
|
||||||
- hook 按职责拆分,文件名匹配实际内容
|
|
||||||
- `TargetDetailDrawer` 拆分为 3 个组件,每个 < 100 行
|
|
||||||
- 类型筛选器由后端 meta API 驱动,新增 checker 前端零改动
|
|
||||||
- 删除 type label 转换层,直接使用 type 原始文本
|
|
||||||
- 补齐前端纯函数测试
|
|
||||||
- 修复已知小问题
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
|
|
||||||
- 不引入路由库或状态管理库
|
|
||||||
- 不重构后端 API 结构
|
|
||||||
- 不改变现有轮询策略和 QueryClient 配置
|
|
||||||
- 不新增 CSS 文件(继续使用单一 `styles.css`)
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: hook 拆分策略
|
|
||||||
|
|
||||||
**选择**:按查询层级拆分为 `use-queries.ts` 和 `use-target-detail.ts`
|
|
||||||
|
|
||||||
- `use-queries.ts`:`queryKeys`、`fetchJson`(不导出)、`useSummary`、`useTargets`、`useMeta`
|
|
||||||
- `use-target-detail.ts`:仅保留 Drawer 状态管理(`selectedTargetId`、时间范围、分页、`openDrawer`/`closeDrawer`)和条件查询(trend/history)
|
|
||||||
|
|
||||||
**理由**:全局查询(summary/targets/meta)是面板级别的,与 Drawer 详情无关。拆分后各文件职责单一,命名自解释。`fetchJson` 仅被 query 层使用,留在 `use-queries.ts` 内部不导出。
|
|
||||||
|
|
||||||
**备选方案**:
|
|
||||||
- 仅重命名为 `useQueries.ts` — 解决命名问题但不解决职责混合
|
|
||||||
- 每个 query 一个文件 — 过度拆分,增加文件数量但无实质收益
|
|
||||||
|
|
||||||
### Decision 2: TargetDetailDrawer 拆分方式
|
|
||||||
|
|
||||||
**选择**:拆为 3 个组件 + 1 个常量文件
|
|
||||||
|
|
||||||
```
|
|
||||||
TargetDetailDrawer.tsx ← Drawer 壳 + 时间选择 + Tab 切换
|
|
||||||
OverviewTab.tsx ← 统计 + TrendChart + StatusDonut + Descriptions
|
|
||||||
HistoryTab.tsx ← PrimaryTable + 分页
|
|
||||||
constants/history-table-columns.tsx ← HISTORY_COLUMNS
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:两个 Tab 的内容完全独立,拆分后各组件 < 100 行。`HISTORY_COLUMNS` 与 `TARGET_TABLE_COLUMNS` 性质相同,应放在 `constants/` 下保持一致。
|
|
||||||
|
|
||||||
统计计算逻辑(`totalChecks`/`upChecks`/`downChecks`)提取为 `utils/stats.ts` 纯函数,便于测试和 `useMemo`。
|
|
||||||
|
|
||||||
### Decision 3: Meta API 设计
|
|
||||||
|
|
||||||
**选择**:`GET /api/meta` 返回 `{ checkerTypes: string[] }`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/shared/api.ts
|
|
||||||
export interface MetaResponse {
|
|
||||||
checkerTypes: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- 从 `checkerRegistry.supportedTypes` 直接获取,无需额外维护
|
|
||||||
- 返回 `string[]` 而非 `{ key, label }[]`,因为决策是不做 label 转换
|
|
||||||
- 端点命名为 `/api/meta` 而非 `/api/types`,为未来扩展预留空间(如版本号、功能开关等)
|
|
||||||
- `staleTime: Infinity`,应用生命周期内只请求一次
|
|
||||||
|
|
||||||
**备选方案**:
|
|
||||||
- 从 targets 响应中动态提取 — 只能获取"当前有数据的类型",不能获取"系统支持的全部类型"
|
|
||||||
- 在 health 端点中附带 — 语义不匹配,health 应保持最小化
|
|
||||||
|
|
||||||
### Decision 4: 类型展示策略
|
|
||||||
|
|
||||||
**选择**:删除 `target-type-display.ts`,所有展示位置直接使用 `target.type` 原始文本
|
|
||||||
|
|
||||||
**影响位置**:
|
|
||||||
- `target-table-columns.tsx` 类型列 cell:`row.type` 直接渲染
|
|
||||||
- `TargetDetailDrawer.tsx` 标题栏 Tag:`target.type` 直接渲染
|
|
||||||
- `typeFilter` 列表:从 meta API 获取,label 和 value 均为原始 type 文本
|
|
||||||
|
|
||||||
**理由**:type 文本本身已足够清晰(`http`、`command`、`tcp`),无需额外映射层。消除了前后端重复维护的问题。
|
|
||||||
|
|
||||||
### Decision 5: 列定义动态化
|
|
||||||
|
|
||||||
**选择**:`TARGET_TABLE_COLUMNS` 从静态常量改为工厂函数
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function createTargetTableColumns(checkerTypes: string[]): PrimaryTableCol<TargetStatus>[] {
|
|
||||||
const typeFilter = {
|
|
||||||
list: [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
...checkerTypes.map(t => ({ label: t, value: t })),
|
|
||||||
],
|
|
||||||
type: "single" as const,
|
|
||||||
};
|
|
||||||
// ... 返回列定义数组
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**数据流**:`useMeta()` → `checkerTypes` → `createTargetTableColumns(checkerTypes)` → `TargetGroup` columns prop
|
|
||||||
|
|
||||||
**理由**:`typeFilter` 是唯一需要动态数据的部分,通过工厂函数注入参数,保持列定义的纯函数特性。`statusFilter` 保持静态(UP/DOWN 是固定的)。
|
|
||||||
|
|
||||||
`TargetGroup` 新增 `columns` prop 接收动态列定义,`TargetBoard` 负责调用工厂函数并传递。
|
|
||||||
|
|
||||||
### Decision 6: StatusBar maxSlots 参数化
|
|
||||||
|
|
||||||
**选择**:新增 `maxSlots` prop(默认 30),组件根据 prop 渲染格数
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface StatusBarProps {
|
|
||||||
samples: Array<{ up: boolean }>;
|
|
||||||
maxSlots?: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:消除硬编码魔数,使组件可复用。默认值 30 保持向后兼容。
|
|
||||||
|
|
||||||
### Decision 7: TrendChart 移除 loading prop
|
|
||||||
|
|
||||||
**选择**:移除 `loading` prop,组件只接收 `data: TrendPoint[]`
|
|
||||||
|
|
||||||
**理由**:调用方(`OverviewTab`)已用 `Skeleton` 处理 loading 状态,`TrendChart` 只在有数据时渲染。组件内部的 `if (loading)` 分支和 `loading={false}` 传参都是死代码。
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
|------|-----------|
|
|
||||||
| 列定义改为函数后,每次 `checkerTypes` 变化会重新创建列数组 | `useMemo` 包裹 `createTargetTableColumns` 调用;meta 数据 `staleTime: Infinity` 确保不会频繁变化 |
|
|
||||||
| meta API 在 targets 之前未返回时,筛选器暂时为空 | meta 请求极轻量(无 DB 查询),通常先于 targets 返回;即使晚到,筛选器会在数据到达后自动出现 |
|
|
||||||
| 拆分后组件间 props 传递增多 | 层级仅增加一层(Drawer → Tab),props 类型明确,不会造成 prop drilling 问题 |
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
无。方案已在 explore 阶段与用户确认。
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
前端代码经过多轮功能迭代后,出现了 hook 职责过重(`useTargetDetail.ts` 承载全部数据层)、组件体积膨胀(`TargetDetailDrawer` 228 行混合多种逻辑)、类型筛选器与后端硬编码重复维护等架构问题。需要通过拆分、动态化和测试补齐来提升可维护性,使新增 checker 类型时前端零改动。
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- 拆分 `hooks/useTargetDetail.ts` 为 `use-queries.ts`(全局查询)和 `use-target-detail.ts`(Drawer 状态管理)
|
|
||||||
- 拆分 `TargetDetailDrawer.tsx` 为 Drawer 壳、`OverviewTab.tsx`、`HistoryTab.tsx` 三个组件
|
|
||||||
- 将 `HISTORY_COLUMNS` 移至 `constants/history-table-columns.tsx`
|
|
||||||
- 提取统计计算逻辑为 `utils/stats.ts` 纯函数
|
|
||||||
- 后端新增 `GET /api/meta` 端点,返回 `checkerTypes` 列表
|
|
||||||
- 前端新增 `useMeta()` hook 消费 meta API,动态生成类型筛选器
|
|
||||||
- **BREAKING** 删除 `constants/target-type-display.ts`,前端直接使用 type 原始文本,不再做 label 转换
|
|
||||||
- 列定义从静态常量改为工厂函数 `createTargetTableColumns(checkerTypes)`
|
|
||||||
- 修复小问题:`StatusDonut` key、`StatusBar` 硬编码、`TrendChart` 冗余 prop、统计计算 useMemo
|
|
||||||
- 补充前端测试:`utils/time.ts`、`utils/stats.ts`、动态列生成
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `meta-api`: 后端 meta 信息 API,提供 checker 类型列表等运行时元数据
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
- `target-type-display`: 移除前端静态映射,改为直接使用后端返回的 type 原始文本,筛选器列表由 meta API 动态驱动
|
|
||||||
- `tanstack-query-data-layer`: hook 文件拆分为 `use-queries.ts` 和 `use-target-detail.ts`,新增 `useMeta()` 查询
|
|
||||||
- `target-detail-drawer`: 组件拆分为 Drawer 壳 + OverviewTab + HistoryTab,统计计算提取为纯函数
|
|
||||||
- `target-table`: 列定义从静态常量改为工厂函数,接收动态 checkerTypes 参数;类型列直接显示 type 原始文本
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- 后端:新增 `src/server/routes/meta.ts`、`src/shared/api.ts` 增加 `MetaResponse` 类型、`app.ts` 注册路由
|
|
||||||
- 前端:hooks/components/constants 目录结构调整,删除 `target-type-display.ts`
|
|
||||||
- 测试:删除 `target-type-display.test.ts`,新增 `time.test.ts`、`stats.test.ts`,更新 `target-table-filters.test.ts`
|
|
||||||
- 文档:更新 DEVELOPMENT.md 中前端目录结构和组件清单
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: TanStack Query 数据层
|
|
||||||
前端 SHALL 使用 TanStack Query(@tanstack/react-query)管理所有 API 请求,数据层代码 SHALL 按职责拆分为独立 hook 文件。
|
|
||||||
|
|
||||||
#### Scenario: QueryClient 配置
|
|
||||||
- **WHEN** 应用启动
|
|
||||||
- **THEN** 系统 SHALL 创建 QueryClient,默认配置 retry=1、refetchOnWindowFocus=true、staleTime=5000
|
|
||||||
|
|
||||||
#### Scenario: QueryClientProvider 挂载
|
|
||||||
- **WHEN** 应用渲染
|
|
||||||
- **THEN** 根组件 SHALL 包裹在 QueryClientProvider 中,提供 QueryClient 实例
|
|
||||||
|
|
||||||
### Requirement: queryKey 工厂
|
|
||||||
系统 SHALL 提供统一的 queryKey 工厂函数,确保 queryKey 的唯一性和一致性。
|
|
||||||
|
|
||||||
#### Scenario: summary queryKey
|
|
||||||
- **WHEN** 查询 summary 数据
|
|
||||||
- **THEN** queryKey SHALL 为 ["summary"]
|
|
||||||
|
|
||||||
#### Scenario: targets queryKey
|
|
||||||
- **WHEN** 查询 targets 数据
|
|
||||||
- **THEN** queryKey SHALL 为 ["targets"]
|
|
||||||
|
|
||||||
#### Scenario: meta queryKey
|
|
||||||
- **WHEN** 查询 meta 数据
|
|
||||||
- **THEN** queryKey SHALL 为 ["meta"]
|
|
||||||
|
|
||||||
#### Scenario: trend queryKey
|
|
||||||
- **WHEN** 查询某目标的趋势数据
|
|
||||||
- **THEN** queryKey SHALL 为 ["trend", targetId, from, to]
|
|
||||||
|
|
||||||
#### Scenario: history queryKey
|
|
||||||
- **WHEN** 查询某目标的历史记录
|
|
||||||
- **THEN** queryKey SHALL 为 ["history", targetId, from, to, page]
|
|
||||||
|
|
||||||
### Requirement: Hook 文件拆分
|
|
||||||
数据层 hook SHALL 按职责拆分为独立文件。
|
|
||||||
|
|
||||||
#### Scenario: 全局查询 hook 文件
|
|
||||||
- **WHEN** 开发者需要使用全局面板级查询
|
|
||||||
- **THEN** `useSummary`、`useTargets`、`useMeta` SHALL 从 `hooks/use-queries.ts` 导出
|
|
||||||
|
|
||||||
#### Scenario: Drawer 状态 hook 文件
|
|
||||||
- **WHEN** 开发者需要使用 Drawer 状态管理
|
|
||||||
- **THEN** `useTargetDetail` SHALL 从 `hooks/use-target-detail.ts` 导出
|
|
||||||
|
|
||||||
#### Scenario: fetchJson 不导出
|
|
||||||
- **WHEN** 数据层内部需要 fetch 封装
|
|
||||||
- **THEN** `fetchJson` SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
|
|
||||||
|
|
||||||
#### Scenario: queryKeys 不导出
|
|
||||||
- **WHEN** 数据层内部需要 query key
|
|
||||||
- **THEN** `queryKeys` 对象 SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
|
|
||||||
|
|
||||||
### Requirement: Meta 查询
|
|
||||||
系统 SHALL 提供 `useMeta` hook 查询系统元数据。
|
|
||||||
|
|
||||||
#### Scenario: meta 查询配置
|
|
||||||
- **WHEN** 应用启动
|
|
||||||
- **THEN** `useMeta` SHALL 请求 `/api/meta`,配置 `staleTime: Infinity`(应用生命周期内只请求一次)
|
|
||||||
|
|
||||||
#### Scenario: meta 数据返回
|
|
||||||
- **WHEN** meta 查询成功
|
|
||||||
- **THEN** hook SHALL 返回 `MetaResponse` 类型数据,包含 `checkerTypes` 字段
|
|
||||||
|
|
||||||
### Requirement: Summary 轮询查询
|
|
||||||
系统 SHALL 使用 useQuery 实现总览统计的自动轮询。
|
|
||||||
|
|
||||||
#### Scenario: summary 自动轮询
|
|
||||||
- **WHEN** Dashboard 页面处于打开状态
|
|
||||||
- **THEN** 系统 SHALL 每 8 秒自动请求 /api/summary,使用 refetchInterval=8000
|
|
||||||
|
|
||||||
#### Scenario: summary 后台刷新
|
|
||||||
- **WHEN** 页面处于后台标签页
|
|
||||||
- **THEN** 系统 SHALL 暂停轮询(refetchIntervalInBackground=false)
|
|
||||||
|
|
||||||
### Requirement: Targets 轮询查询
|
|
||||||
系统 SHALL 使用 useQuery 实现目标列表的自动轮询。
|
|
||||||
|
|
||||||
#### Scenario: targets 自动轮询
|
|
||||||
- **WHEN** Dashboard 页面处于打开状态
|
|
||||||
- **THEN** 系统 SHALL 每 8 秒自动请求 /api/targets,使用 refetchInterval=8000
|
|
||||||
|
|
||||||
### Requirement: 条件查询
|
|
||||||
趋势和历史记录查询 SHALL 使用 enabled 条件控制,仅在目标被选中时触发。
|
|
||||||
|
|
||||||
#### Scenario: 未选中目标时不请求
|
|
||||||
- **WHEN** 用户未点击任何目标表格行
|
|
||||||
- **THEN** trend 和 history 的 useQuery SHALL enabled=false,不发起请求
|
|
||||||
|
|
||||||
#### Scenario: 选中目标时自动请求
|
|
||||||
- **WHEN** 用户点击目标表格行
|
|
||||||
- **THEN** trend 和 history 的 useQuery SHALL enabled=true,自动发起请求
|
|
||||||
|
|
||||||
#### Scenario: 时间范围变化时重新请求
|
|
||||||
- **WHEN** 用户更改时间范围
|
|
||||||
- **THEN** trend 和 history 的 useQuery SHALL 因 queryKey 变化自动重新请求
|
|
||||||
|
|
||||||
### Requirement: 开发调试面板
|
|
||||||
开发环境下 SHALL 挂载 TanStack Query Devtools。
|
|
||||||
|
|
||||||
#### Scenario: 开发环境显示 Devtools
|
|
||||||
- **WHEN** 应用在开发模式下运行
|
|
||||||
- **THEN** 页面 SHALL 显示 ReactQueryDevtools 浮动面板
|
|
||||||
|
|
||||||
#### Scenario: 生产环境排除 Devtools
|
|
||||||
- **WHEN** 应用在生产模式下构建
|
|
||||||
- **THEN** ReactQueryDevtools SHALL 不被包含在产物中
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: 目标详情 Drawer
|
|
||||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。
|
|
||||||
|
|
||||||
#### Scenario: 打开 Drawer
|
|
||||||
- **WHEN** 用户点击某个目标表格行
|
|
||||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right"),宽度为视口 60%
|
|
||||||
|
|
||||||
#### Scenario: Drawer 标题栏
|
|
||||||
- **WHEN** Drawer 渲染
|
|
||||||
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件(align="center")布局,包含 StatusDot、目标名称(TDesign Typography.Text strong)和类型标签(TDesign Tag,直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局
|
|
||||||
|
|
||||||
#### Scenario: 关闭 Drawer
|
|
||||||
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
|
|
||||||
- **THEN** Drawer SHALL 关闭
|
|
||||||
|
|
||||||
#### Scenario: Drawer 无底部按钮
|
|
||||||
- **WHEN** Drawer 渲染
|
|
||||||
- **THEN** Drawer SHALL 不显示底部操作栏(footer={false})
|
|
||||||
|
|
||||||
#### Scenario: Drawer 数据同步
|
|
||||||
- **WHEN** Drawer 打开期间后台轮询刷新了 targets 数据
|
|
||||||
- **THEN** Drawer 中 selectedTarget 的状态 SHALL 随之同步更新
|
|
||||||
|
|
||||||
#### Scenario: 切换目标重置 Tab
|
|
||||||
- **WHEN** 用户从目标 A 切换到目标 B(点击不同的表格行)
|
|
||||||
- **THEN** Drawer SHALL 重置为概览 Tab,使用 key={target.id} 确保组件状态不残留
|
|
||||||
|
|
||||||
#### Scenario: Drawer 内容区间距
|
|
||||||
- **WHEN** Drawer 内容渲染
|
|
||||||
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件(direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
|
|
||||||
|
|
||||||
### Requirement: 概览面板组件化
|
|
||||||
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,接收数据 props 进行渲染。
|
|
||||||
|
|
||||||
#### Scenario: OverviewTab 组件职责
|
|
||||||
- **WHEN** 概览 Tab 渲染
|
|
||||||
- **THEN** `OverviewTab` 组件 SHALL 负责统计卡片、趋势图、状态分布环形图和基本信息的渲染
|
|
||||||
|
|
||||||
#### Scenario: 统计计算使用纯函数
|
|
||||||
- **WHEN** OverviewTab 需要计算 totalChecks、upChecks、downChecks
|
|
||||||
- **THEN** 计算逻辑 SHALL 通过 `utils/stats.ts` 中的纯函数实现,并使用 `useMemo` 缓存结果
|
|
||||||
|
|
||||||
#### Scenario: OverviewTab props
|
|
||||||
- **WHEN** OverviewTab 渲染
|
|
||||||
- **THEN** 组件 SHALL 接收 `target: TargetStatus`、`trendData: TrendPoint[]`、`trendLoading: boolean` 作为 props
|
|
||||||
|
|
||||||
### Requirement: 记录面板组件化
|
|
||||||
记录 Tab SHALL 作为独立组件 `HistoryTab` 实现。
|
|
||||||
|
|
||||||
#### Scenario: HistoryTab 组件职责
|
|
||||||
- **WHEN** 记录 Tab 渲染
|
|
||||||
- **THEN** `HistoryTab` 组件 SHALL 负责检查结果表格和分页的渲染
|
|
||||||
|
|
||||||
#### Scenario: HistoryTab props
|
|
||||||
- **WHEN** HistoryTab 渲染
|
|
||||||
- **THEN** 组件 SHALL 接收 `historyData: HistoryResponse`、`historyLoading: boolean`、`onPageChange: (page: number) => void` 作为 props
|
|
||||||
|
|
||||||
#### Scenario: 历史记录列定义外置
|
|
||||||
- **WHEN** HistoryTab 渲染表格
|
|
||||||
- **THEN** 列定义 SHALL 从 `constants/history-table-columns.tsx` 导入,不在组件内部定义
|
|
||||||
|
|
||||||
### Requirement: TrendChart 简化
|
|
||||||
TrendChart 组件 SHALL 仅接收数据 props,不处理 loading 状态。
|
|
||||||
|
|
||||||
#### Scenario: TrendChart 无 loading prop
|
|
||||||
- **WHEN** TrendChart 渲染
|
|
||||||
- **THEN** 组件 SHALL 仅接收 `data: TrendPoint[]` prop,不接收 `loading` prop
|
|
||||||
|
|
||||||
#### Scenario: TrendChart 空数据
|
|
||||||
- **WHEN** TrendChart 接收空数组
|
|
||||||
- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本
|
|
||||||
|
|
||||||
### Requirement: StatusDonut key 修复
|
|
||||||
StatusDonut 组件 SHALL 使用语义化的 key。
|
|
||||||
|
|
||||||
#### Scenario: Pie Cell key
|
|
||||||
- **WHEN** StatusDonut 渲染 Pie Cell 列表
|
|
||||||
- **THEN** 每个 Cell 的 key SHALL 使用 data item 的 `name` 字段,不使用数组索引
|
|
||||||
|
|
||||||
### Requirement: StatusBar 参数化
|
|
||||||
StatusBar 组件 SHALL 支持可配置的格数。
|
|
||||||
|
|
||||||
#### Scenario: maxSlots prop
|
|
||||||
- **WHEN** StatusBar 渲染
|
|
||||||
- **THEN** 组件 SHALL 接收可选的 `maxSlots` prop(默认 30),根据该值渲染对应数量的格子
|
|
||||||
|
|
||||||
#### Scenario: 格子渲染逻辑
|
|
||||||
- **WHEN** StatusBar 渲染且 samples 数量少于 maxSlots
|
|
||||||
- **THEN** 多余的格子 SHALL 显示为 empty 状态
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: 表格列定义
|
|
||||||
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
|
|
||||||
|
|
||||||
#### Scenario: 状态列
|
|
||||||
- **WHEN** 表格渲染
|
|
||||||
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60px,fixed="left",居中对齐,支持筛选(UP/DOWN/全部)。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style
|
|
||||||
|
|
||||||
#### Scenario: 名称列
|
|
||||||
- **WHEN** 表格渲染
|
|
||||||
- **THEN** 名称列 SHALL 显示目标名称,支持字母排序(zh-CN),ellipsis 超长名称自动省略并 Tooltip 显示全名
|
|
||||||
|
|
||||||
#### Scenario: 类型列
|
|
||||||
- **WHEN** 表格渲染
|
|
||||||
- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)直接显示 target.type 原始文本,支持单选筛选
|
|
||||||
|
|
||||||
#### Scenario: 类型筛选器动态生成
|
|
||||||
- **WHEN** 表格渲染
|
|
||||||
- **THEN** 类型列的筛选器列表 SHALL 从 meta API 返回的 `checkerTypes` 动态生成,包含"全部"选项和每个 checker 类型选项(label 和 value 均为 type 原始文本)
|
|
||||||
|
|
||||||
#### Scenario: 可用率列
|
|
||||||
- **WHEN** 表格渲染
|
|
||||||
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色通过 CSS 自定义属性 `--avail-N`(基于项目自定义色值)控制,每 10% 一档,label 显示百分比数值,支持排序(升序优先,最差排最前)。color-threshold 函数 SHALL 返回 CSS 自定义属性引用而非硬编码色值
|
|
||||||
|
|
||||||
#### Scenario: 最近状态列
|
|
||||||
- **WHEN** 表格渲染
|
|
||||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style
|
|
||||||
|
|
||||||
#### Scenario: 延迟列
|
|
||||||
- **WHEN** 表格渲染
|
|
||||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐。颜色 SHALL 通过 CSS 类实现:≤100ms 使用 `.latency-ok`、100-500ms 使用 `.latency-warn`、>500ms 使用 `.latency-error`。无数据 SHALL 使用 `.text-disabled` 类显示 "-",数值 SHALL 使用 `.tabular-nums` 类等宽显示。不使用内联 style
|
|
||||||
|
|
||||||
#### Scenario: 间隔列
|
|
||||||
- **WHEN** 表格渲染
|
|
||||||
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
|
|
||||||
|
|
||||||
### Requirement: 列定义工厂函数
|
|
||||||
列定义 SHALL 通过工厂函数生成,接收动态参数。
|
|
||||||
|
|
||||||
#### Scenario: createTargetTableColumns 函数
|
|
||||||
- **WHEN** 需要生成表格列定义
|
|
||||||
- **THEN** 系统 SHALL 调用 `createTargetTableColumns(checkerTypes: string[])` 函数,返回 `PrimaryTableCol<TargetStatus>[]`
|
|
||||||
|
|
||||||
#### Scenario: checkerTypes 为空数组
|
|
||||||
- **WHEN** meta API 尚未返回或返回空数组
|
|
||||||
- **THEN** 类型列的筛选器 SHALL 仅包含"全部"选项
|
|
||||||
|
|
||||||
#### Scenario: 列定义缓存
|
|
||||||
- **WHEN** TargetBoard 组件渲染
|
|
||||||
- **THEN** 列定义 SHALL 通过 `useMemo` 缓存,仅在 `checkerTypes` 变化时重新生成
|
|
||||||
|
|
||||||
### Requirement: TargetGroup 接收 columns prop
|
|
||||||
TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态常量。
|
|
||||||
|
|
||||||
#### Scenario: columns prop
|
|
||||||
- **WHEN** TargetGroup 渲染
|
|
||||||
- **THEN** 组件 SHALL 接收 `columns: PrimaryTableCol<TargetStatus>[]` prop 并传递给 PrimaryTable
|
|
||||||
|
|
||||||
#### Scenario: TargetBoard 传递 columns
|
|
||||||
- **WHEN** TargetBoard 渲染子组件
|
|
||||||
- **THEN** TargetBoard SHALL 调用 `createTargetTableColumns` 生成列定义并传递给每个 TargetGroup
|
|
||||||
|
|
||||||
### Requirement: 列定义复用
|
|
||||||
所有分组的表格 SHALL 共享同一套列定义常量。
|
|
||||||
|
|
||||||
#### Scenario: 列定义提取为常量
|
|
||||||
- **WHEN** 多个分组表格渲染
|
|
||||||
- **THEN** 列定义 SHALL 从独立的 constants/target-table-columns.tsx 导入,不在组件中重复定义
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
## REMOVED Requirements
|
|
||||||
|
|
||||||
### Requirement: 类型显示名称映射
|
|
||||||
**Reason**: 前端不再维护 type → label 的静态映射,直接使用后端返回的 type 原始文本展示。类型筛选器列表改由 meta API 动态驱动。
|
|
||||||
**Migration**: 所有使用 `getTargetTypeDisplay(type)` 的位置改为直接使用 `type` 字符串。`TARGET_TYPE_DISPLAY` 常量和 `target-type-display.ts` 文件删除。
|
|
||||||
|
|
||||||
### Requirement: 映射可扩展性
|
|
||||||
**Reason**: 不再需要前端映射扩展机制,新增 checker 类型时后端注册即自动通过 meta API 暴露给前端。
|
|
||||||
**Migration**: 无需迁移,删除即可。
|
|
||||||
|
|
||||||
### Requirement: 类型安全
|
|
||||||
**Reason**: 不再有映射常量,无需 TypeScript 类型推导和 fallback 逻辑。
|
|
||||||
**Migration**: 无需迁移,删除即可。
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
## 1. 后端 Meta API
|
|
||||||
|
|
||||||
- [ ] 1.1 在 `src/shared/api.ts` 中新增 `MetaResponse` 类型定义
|
|
||||||
- [ ] 1.2 创建 `src/server/routes/meta.ts`,实现 `handleMeta` 从 `checkerRegistry.supportedTypes` 返回数据
|
|
||||||
- [ ] 1.3 在 `src/server/app.ts` 中注册 `/api/meta` 路由
|
|
||||||
- [ ] 1.4 在 `tests/server/app.test.ts` 中添加 `/api/meta` 端点测试(GET/HEAD/405)
|
|
||||||
|
|
||||||
## 2. 前端 Hook 拆分
|
|
||||||
|
|
||||||
- [ ] 2.1 创建 `src/web/hooks/use-queries.ts`,迁入 `queryKeys`、`fetchJson`、`useSummary`、`useTargets`,新增 `useMeta`
|
|
||||||
- [ ] 2.2 重写 `src/web/hooks/use-target-detail.ts`,仅保留 Drawer 状态管理和条件查询(trend/history)
|
|
||||||
- [ ] 2.3 更新 `src/web/app.tsx` 的 import 路径适配新 hook 文件
|
|
||||||
|
|
||||||
## 3. 前端组件拆分
|
|
||||||
|
|
||||||
- [ ] 3.1 创建 `src/web/utils/stats.ts`,提取统计计算纯函数(`computeTrendStats`)
|
|
||||||
- [ ] 3.2 创建 `src/web/constants/history-table-columns.tsx`,将 `HISTORY_COLUMNS` 从 Drawer 中移出
|
|
||||||
- [ ] 3.3 创建 `src/web/components/OverviewTab.tsx`,从 TargetDetailDrawer 中提取概览面板逻辑
|
|
||||||
- [ ] 3.4 创建 `src/web/components/HistoryTab.tsx`,从 TargetDetailDrawer 中提取记录面板逻辑
|
|
||||||
- [ ] 3.5 精简 `src/web/components/TargetDetailDrawer.tsx`,仅保留 Drawer 壳 + 时间选择 + Tab 切换
|
|
||||||
|
|
||||||
## 4. 类型筛选器动态化
|
|
||||||
|
|
||||||
- [ ] 4.1 将 `src/web/constants/target-table-columns.tsx` 中的 `TARGET_TABLE_COLUMNS` 改为工厂函数 `createTargetTableColumns(checkerTypes)`
|
|
||||||
- [ ] 4.2 从 `src/web/constants/target-table-filters.ts` 中移除 `typeFilter`(`statusFilter` 保留)
|
|
||||||
- [ ] 4.3 更新 `src/web/components/TargetBoard.tsx`,调用 `useMeta` + `useMemo` 生成列定义并传递给 TargetGroup
|
|
||||||
- [ ] 4.4 更新 `src/web/components/TargetGroup.tsx`,新增 `columns` prop 替代静态导入
|
|
||||||
- [ ] 4.5 删除 `src/web/constants/target-type-display.ts`
|
|
||||||
- [ ] 4.6 更新 `src/web/components/TargetDetailDrawer.tsx` 标题栏,直接使用 `target.type` 替代 `getTargetTypeDisplay`
|
|
||||||
|
|
||||||
## 5. 小问题修复
|
|
||||||
|
|
||||||
- [ ] 5.1 修复 `StatusDonut.tsx`:Cell key 从 `index` 改为 `data[index].name`
|
|
||||||
- [ ] 5.2 修复 `StatusBar.tsx`:新增 `maxSlots` prop(默认 30),用 prop 驱动格数渲染
|
|
||||||
- [ ] 5.3 修复 `TrendChart.tsx`:移除 `loading` prop,仅保留 `data` prop
|
|
||||||
|
|
||||||
## 6. 测试补充与更新
|
|
||||||
|
|
||||||
- [ ] 6.1 创建 `tests/web/utils/time.test.ts`,测试 `subtractHours`(正常、跨天、跨月、0 小时)
|
|
||||||
- [ ] 6.2 创建 `tests/web/utils/stats.test.ts`,测试 `computeTrendStats` 纯函数
|
|
||||||
- [ ] 6.3 更新 `tests/web/constants/target-table-filters.test.ts`,移除 `typeFilter` 相关测试
|
|
||||||
- [ ] 6.4 删除 `tests/web/constants/target-type-display.test.ts`
|
|
||||||
- [ ] 6.5 创建 `tests/web/constants/target-table-columns.test.ts`,测试 `createTargetTableColumns` 工厂函数
|
|
||||||
|
|
||||||
## 7. 质量保障与文档
|
|
||||||
|
|
||||||
- [ ] 7.1 执行 `bun run check` 确保类型检查、lint、测试全部通过
|
|
||||||
- [ ] 7.2 更新 DEVELOPMENT.md 中前端目录结构、组件清单和 hook 说明
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
created: 2026-05-13
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
## Context
|
|
||||||
|
|
||||||
HTTP checker 是 DiAL 拨测系统的核心 runner 之一,负责对 HTTP 目标执行请求并校验响应。经审查发现以下质量问题:
|
|
||||||
|
|
||||||
1. **actual 值截断格式不符合 spec**:spec 要求 failure 中的 actual 摘要需截断并附带字符计数,但当前 `truncateActual` 函数只加省略号无计数,导致用户无法判断原始响应体规模。
|
|
||||||
2. **ReDoS 风险**:用户配置的 regex body 规则和 match operator 直接对大响应体执行 `new RegExp().test()`,恶意或不当正则可能导致 CPU 阻塞。
|
|
||||||
3. **JSON 重复解析**:多条 json body 规则各自独立调用 `JSON.parse(body)`,对大 JSON 响应体造成不必要的重复开销。
|
|
||||||
4. **CSS 规则分支冗余**:`checkCssRule` 中"无 operator 时检查元素存在"和"exists: true"是重复逻辑。
|
|
||||||
5. **重定向测试不足**:303、307/308、相对路径 Location 等分支缺少测试覆盖。
|
|
||||||
|
|
||||||
当前代码结构:
|
|
||||||
- `src/server/checker/expect/failure.ts` — failure 构造函数
|
|
||||||
- `src/server/checker/runner/http/body.ts` — body 规则检查
|
|
||||||
- `src/server/checker/runner/http/execute.ts` — HTTP 执行主流程
|
|
||||||
- `src/server/checker/expect/operator.ts` — operator 匹配逻辑
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- 实现 failure actual 值截断,满足 spec 要求
|
|
||||||
- 消除 regex 相关的 ReDoS 风险
|
|
||||||
- 优化多条 JSON 规则的解析性能
|
|
||||||
- 精简冗余代码分支
|
|
||||||
- 补全重定向和集成测试覆盖
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- 不改变 CheckResult / CheckFailure 的类型结构(截断在构造时完成,对外接口不变)
|
|
||||||
- 不引入新依赖
|
|
||||||
- 不改变 HTTP checker 的功能行为(纯内部质量改进)
|
|
||||||
- 不添加 response timing 分段记录(暂缓)
|
|
||||||
- 不添加重试机制(拨测场景下重试会掩盖网络问题信号)
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: actual 截断在 mismatchFailure 构造点统一实施
|
|
||||||
|
|
||||||
**选择**:在 `expect/failure.ts` 的 `mismatchFailure` 函数内部对 actual 参数截断,阈值 200 字符。
|
|
||||||
|
|
||||||
**替代方案**:
|
|
||||||
- 在存储层(store.ts insertCheckResult)截断 — 但这样 API 实时返回的 failure 仍然很大
|
|
||||||
- 在每个调用点手动截断 — 分散且容易遗漏
|
|
||||||
|
|
||||||
**理由**:构造点截断是最集中的拦截位置,所有 mismatch failure 都经过此函数,一处修改全局生效。expected 值不截断(来自用户配置,通常很短)。
|
|
||||||
|
|
||||||
**截断格式**:`<前 200 字符>…(共 N 字符)` — 保留前缀便于诊断,附带总长度便于判断规模(省略号为单字符 U+2026)。
|
|
||||||
|
|
||||||
### Decision 2: ReDoS 防护使用正则复杂度静态检测
|
|
||||||
|
|
||||||
**选择**:在启动期 validate 阶段对 regex body 规则和 match operator 进行静态复杂度检测,拒绝含有嵌套量词等危险模式的正则。运行期不做额外防护。
|
|
||||||
|
|
||||||
**替代方案**:
|
|
||||||
- 运行期用 AbortSignal + setTimeout 强制中断 — Bun 的 RegExp 执行不可中断,无法实现
|
|
||||||
- 使用 safe-regex 库 — 引入新依赖,违反项目规范
|
|
||||||
- 限制正则执行的输入长度 — 会影响正常大响应体的匹配
|
|
||||||
|
|
||||||
**理由**:自行实现轻量级检测函数,检查常见 ReDoS 模式(嵌套量词 `(a+)+`、重叠交替 `(a|a)*`)。在 validate 阶段拒绝危险正则,比运行期防护更可靠——配置错误应该在启动时暴露。
|
|
||||||
|
|
||||||
**检测规则**:
|
|
||||||
- 嵌套量词:量词内包含量词(如 `(a+)+`、`(a*)*`、`(a+)*`)
|
|
||||||
- 重叠字符类交替后跟量词:`(x|x)+` 模式
|
|
||||||
|
|
||||||
### Decision 3: JSON parse 结果缓存在 checkBodyExpect 层
|
|
||||||
|
|
||||||
**选择**:在 `checkBodyExpect` 函数中,首次遇到 json 规则时执行 `JSON.parse`,将结果缓存并传递给后续 json 规则复用。
|
|
||||||
|
|
||||||
**实现方式**:修改 `checkSingleBodyRule` 签名,接受可选的 `parsedJson` 参数;在 `checkBodyExpect` 循环中维护一个 `let parsedJson: { ok: boolean; value?: unknown; error?: string }` 状态。
|
|
||||||
|
|
||||||
**理由**:改动最小,不改变外部接口,只在内部传递缓存。对于非 json 规则(contains、regex、css、xpath)无影响。
|
|
||||||
|
|
||||||
### Decision 4: CSS 规则分支合并策略
|
|
||||||
|
|
||||||
**选择**:将 `checkCssRule` 重构为线性流程:
|
|
||||||
1. 解析 HTML
|
|
||||||
2. 处理 `exists: false`(元素不存在即通过)
|
|
||||||
3. 查找元素(不存在则失败)
|
|
||||||
4. 处理 `exists: true`(到这里已确认存在,直接通过)
|
|
||||||
5. 提取值(attr 或 text)
|
|
||||||
6. 无 operator 时检查值非 undefined 即通过
|
|
||||||
7. 有 operator 时执行匹配
|
|
||||||
|
|
||||||
**理由**:消除当前三层嵌套判断中的重复逻辑,使控制流线性化,更易理解和维护。
|
|
||||||
|
|
||||||
### Decision 5: execute.ts 提前 duration 检查保留但加注释
|
|
||||||
|
|
||||||
**选择**:保留第 56-74 行的提前 duration 检查逻辑(它是有效的性能优化——避免读取注定超时的 body),但重构为独立的 helper 函数使意图更明确。
|
|
||||||
|
|
||||||
**理由**:删除它会导致超时场景下仍然读取完整 body 后才报错,浪费网络带宽和时间。提取为 `checkEarlyTimeout` 函数名即可自解释。
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
|
||||||
|
|
||||||
- **ReDoS 静态检测的误报**:过于严格的检测可能拒绝合法但看起来复杂的正则。→ 缓解:只检测最常见的嵌套量词模式,不做过度分析;提供清晰的错误信息指导用户修改。
|
|
||||||
- **actual 截断丢失诊断信息**:截断后用户无法看到完整 actual 值。→ 缓解:200 字符的前缀通常足够定位问题;如需完整响应体,用户应直接请求目标 URL 查看。
|
|
||||||
- **JSON parse 缓存的内存占用**:对于大 JSON 响应体,缓存的 parsed 对象会在整个 body rules 检查期间驻留内存。→ 缓解:这是短暂的(单次检查周期内),且原本每条规则都会各自 parse 一份,缓存反而减少了峰值内存。
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
## Why
|
|
||||||
|
|
||||||
HTTP checker 经过审查发现若干质量问题:failure 中 actual 值截断格式不符合 spec 要求(缺少字符计数)导致诊断信息不完整、regex 规则缺少 ReDoS 防护存在 CPU 阻塞风险、多条 JSON body 规则重复 parse 造成不必要开销、CSS 规则分支冗余、重定向测试覆盖不足。需要统一修复以提升健壮性和代码质量。
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- 修正 `mismatchFailure` 中 actual 值截断格式,添加字符计数信息,格式为 `前 N 字符…(共 M 字符)`
|
|
||||||
- 为 regex body 规则和 match operator 添加 ReDoS 防护(执行超时或正则复杂度检测)
|
|
||||||
- 优化多条 JSON body 规则共享同一次 `JSON.parse` 结果,避免重复解析
|
|
||||||
- 精简 `body.ts` 中 `checkCssRule` 的冗余分支逻辑
|
|
||||||
- 精简 `execute.ts` 中提前 duration 检查的代码结构
|
|
||||||
- 补充重定向相关测试:303 method 转换、307/308 保持 method、相对路径 Location、混合 body rules 集成测试
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
- `expect-body-checkers`: 新增 actual 值截断的具体实现要求(spec 已声明但未细化截断阈值和格式)
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- `src/server/checker/expect/failure.ts` — 新增截断逻辑
|
|
||||||
- `src/server/checker/runner/http/body.ts` — JSON parse 优化、CSS 分支精简
|
|
||||||
- `src/server/checker/runner/http/execute.ts` — duration 检查精简
|
|
||||||
- `src/server/checker/expect/operator.ts` — match operator ReDoS 防护
|
|
||||||
- `tests/server/checker/runner/http/runner.test.ts` — 补充重定向和集成测试
|
|
||||||
- `tests/server/checker/runner/shared/body.test.ts` — 补充截断相关测试
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
> 注:仅展示变更的 scenarios,其余 scenarios 保持不变
|
|
||||||
|
|
||||||
### Requirement: 结构化 expect 失败信息
|
|
||||||
系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符,超出部分以省略标记和总字符数替代。expected 值不截断。
|
|
||||||
|
|
||||||
#### Scenario: body 规则失败信息
|
|
||||||
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
|
|
||||||
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
|
|
||||||
|
|
||||||
#### Scenario: actual 值截断
|
|
||||||
- **WHEN** 失败规则的实际值为字符串且长度超过 200 字符
|
|
||||||
- **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数
|
|
||||||
|
|
||||||
#### Scenario: actual 值未超限
|
|
||||||
- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符
|
|
||||||
- **THEN** failure.actual SHALL 保留完整原始值,不做截断
|
|
||||||
|
|
||||||
#### Scenario: actual 值为对象或数组
|
|
||||||
- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符
|
|
||||||
- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀
|
|
||||||
|
|
||||||
#### Scenario: actual 值为标量
|
|
||||||
- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined
|
|
||||||
- **THEN** failure.actual SHALL 保留原始值,不做截断
|
|
||||||
|
|
||||||
### Requirement: HTTP expect 规则启动期校验
|
|
||||||
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operator;body 提取规则可以不配置 operator,并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value,包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。
|
|
||||||
|
|
||||||
#### Scenario: body rule 使用 regex 字段
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
|
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
|
|
||||||
|
|
||||||
#### Scenario: body rule 不支持 match 字段
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: body rule 未知字段启动失败
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `note` 是未知字段
|
|
||||||
|
|
||||||
#### Scenario: body rule 多支持字段非法
|
|
||||||
- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: operator match 正则非法
|
|
||||||
- **WHEN** HTTP target 的 expect.headers、json、css 或 xpath operator 配置了不可编译的 match 正则
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: operator 数值比较类型非法
|
|
||||||
- **WHEN** HTTP target 的 expect operator 配置 gt、gte、lt 或 lte,且对应值不是有限数字
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: operator 布尔类型非法
|
|
||||||
- **WHEN** HTTP target 的 expect operator 配置 empty 或 exists,且对应值不是布尔值
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: JSONPath 子集非法
|
|
||||||
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: operator 未知字段非法
|
|
||||||
- **WHEN** HTTP target 的 expect operator 配置了 `foo: "bar"` 等未知 operator 字段
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
|
||||||
|
|
||||||
#### Scenario: equals 支持对象
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.payload", equals: {status: "ok"}}}]`
|
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和对象期望
|
|
||||||
|
|
||||||
#### Scenario: equals 支持数组
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.items", equals: ["a", "b"]}}]`
|
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和数组期望
|
|
||||||
|
|
||||||
#### Scenario: 纯 operator 对象不能为空
|
|
||||||
- **WHEN** HTTP target 的 `expect.headers` 中某个 header 期望配置为空对象 `{}`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,要求显式配置至少一个 operator
|
|
||||||
|
|
||||||
#### Scenario: json rule 允许存在性语义
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`
|
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期以 JSONPath 值存在作为通过语义
|
|
||||||
|
|
||||||
#### Scenario: css rule 未知字段非法
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "h1", unknown: true}}]`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
|
|
||||||
|
|
||||||
#### Scenario: xpath rule 未知字段非法
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
|
|
||||||
|
|
||||||
#### Scenario: regex body 规则含嵌套量词启动失败
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
|
|
||||||
|
|
||||||
#### Scenario: match operator 含嵌套量词启动失败
|
|
||||||
- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}`
|
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
|
|
||||||
|
|
||||||
#### Scenario: 安全正则通过校验
|
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]`
|
|
||||||
- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
## 1. failure actual 截断
|
|
||||||
|
|
||||||
- [ ] 1.1 修改 `src/server/checker/expect/failure.ts` 中 `truncateActual` 函数,截断后缀从 `...` 改为 `…(共 N 字符)`,其中省略号为单字符 U+2026
|
|
||||||
- [ ] 1.2 更新 `tests/server/checker/runner/shared/failure.test.ts` 中截断相关测试断言,匹配新格式(检查省略号为单字符且带字符计数)
|
|
||||||
|
|
||||||
## 2. ReDoS 防护
|
|
||||||
|
|
||||||
- [ ] 2.1 在 `src/server/checker/expect/` 下新增 `redos.ts`,实现 `isUnsafeRegex(pattern: string): boolean` 函数,检测嵌套量词模式
|
|
||||||
- [ ] 2.2 在 `src/server/checker/runner/http/validate.ts` 的 `validateRegexRule` 和 `src/server/checker/expect/validate-operator.ts` 的 match 校验中调用 `isUnsafeRegex`,不安全时返回 issue
|
|
||||||
- [ ] 2.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 ReDoS 正则启动校验失败的测试用例
|
|
||||||
- [ ] 2.4 在 `tests/server/checker/runner/shared/` 下新增 `redos.test.ts`,覆盖常见 ReDoS 模式和安全正则的判定
|
|
||||||
|
|
||||||
## 3. JSON parse 优化
|
|
||||||
|
|
||||||
- [ ] 3.1 修改 `src/server/checker/runner/http/body.ts` 中 `checkBodyExpect` 函数,维护 parsedJson 缓存状态,首次 json 规则 parse 后复用结果
|
|
||||||
- [ ] 3.2 修改 `checkJsonRule` 签名接受可选的预解析 JSON 对象,避免重复 `JSON.parse`
|
|
||||||
- [ ] 3.3 在 `tests/server/checker/runner/shared/body.test.ts` 中补充多条 json 规则共享 parse 结果的测试(验证行为正确性)
|
|
||||||
|
|
||||||
## 4. CSS 规则精简
|
|
||||||
|
|
||||||
- [ ] 4.1 重构 `src/server/checker/runner/http/body.ts` 中 `checkCssRule` 为线性流程:解析 HTML → exists:false 短路 → 查找元素 → exists:true 短路 → 提取值 → operator 匹配
|
|
||||||
- [ ] 4.2 确认 `tests/server/checker/runner/shared/body.test.ts` 中现有 CSS 测试全部通过
|
|
||||||
|
|
||||||
## 5. execute.ts 精简
|
|
||||||
|
|
||||||
- [ ] 5.1 将 `src/server/checker/runner/http/execute.ts` 第 56-74 行的提前 duration 检查提取为 `checkEarlyTimeout` 辅助函数,明确意图
|
|
||||||
|
|
||||||
## 6. 补充测试
|
|
||||||
|
|
||||||
- [ ] 6.1 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 303 重定向 method 转 GET 的测试
|
|
||||||
- [ ] 6.2 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 307/308 保持原始 method 和 body 的测试
|
|
||||||
- [ ] 6.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充相对路径 Location header 重定向的测试
|
|
||||||
- [ ] 6.4 在 `tests/server/checker/runner/http/runner.test.ts` 中补充混合 body rules(contains + json + css)集成测试
|
|
||||||
|
|
||||||
## 7. 质量保障
|
|
||||||
|
|
||||||
- [ ] 7.1 执行完整测试套件 `bun test`、代码检查 `bun run lint`、格式检查 `bun run format:check` 确保无回归
|
|
||||||
- [ ] 7.2 更新 DEVELOPMENT.md 中 ReDoS 校验相关说明(如有必要)
|
|
||||||
@@ -10,7 +10,7 @@ context: |
|
|||||||
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
- 这是基于bun实现的前端后一体化项目,使用bun作为唯一包管理器,严禁使用pnpm、npm,使用bunx运行工具,严禁使用npx、pnpx
|
||||||
- src/server目录下是基于bun实现的后端代码
|
- src/server目录下是基于bun实现的后端代码
|
||||||
- 后端库使用优先级:Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
|
- 后端库使用优先级:Bun 内置 API > es-toolkit > 主流三方库 > 项目公共工具 > 自行实现
|
||||||
- src/web目录下是基于vite、react、TDesign实现的前端代码
|
- src/web目录下是基于Bun HTML import、React、TDesign实现的前端代码
|
||||||
- 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
|
- 前端样式开发优先级:TDesign组件 > 组件props > TDesign CSS tokens(--td-*) > styles.css CSS类 > 自行开发组件
|
||||||
- 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
|
- 前端严禁:组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
|
||||||
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、参数校验逻辑抽取为中间件、静态资源服务独立维护。
|
定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、路径参数由 Bun routes 解析,静态资源服务由 HTML import manifest 管理。
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
||||||
### Requirement: 路由按职责拆分
|
### Requirement: 路由按职责拆分
|
||||||
系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出一个 handler 函数供 app.ts 统一注册。
|
系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出 route handler 函数供 routes 对象统一注册。
|
||||||
|
|
||||||
#### Scenario: health 端点独立路由
|
#### Scenario: health 端点独立路由
|
||||||
- **WHEN** 客户端请求 `GET /health`
|
- **WHEN** 客户端请求 `GET /health`
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
|
|
||||||
#### Scenario: history 端点独立路由
|
#### Scenario: history 端点独立路由
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/:id/history?from=ISO&to=ISO`
|
- **WHEN** 客户端请求 `GET /api/targets/:id/history?from=ISO&to=ISO`
|
||||||
- **THEN** `routes/history.ts` 导出的 handler 负责处理,包含参数校验、store 查询和 HistoryResponse 返回
|
- **THEN** `routes/history.ts` 导出的 handler 负责处理,通过 `req.params.id` 获取路径参数,包含参数校验、store 查询和 HistoryResponse 返回
|
||||||
|
|
||||||
#### Scenario: trend 端点独立路由
|
#### Scenario: trend 端点独立路由
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/:id/trend?from=ISO&to=ISO`
|
- **WHEN** 客户端请求 `GET /api/targets/:id/trend?from=ISO&to=ISO`
|
||||||
- **THEN** `routes/trend.ts` 导出的 handler 负责处理,包含参数校验、store 查询和 TrendPoint[] 返回
|
- **THEN** `routes/trend.ts` 导出的 handler 负责处理,通过 `req.params.id` 获取路径参数,包含参数校验、store 查询和 TrendPoint[] 返回
|
||||||
|
|
||||||
### Requirement: 共享辅助函数集中管理
|
### Requirement: 共享辅助函数集中管理
|
||||||
系统 SHALL 将跨路由共享的响应格式化函数抽取到 helpers.ts 模块,单一职责、集中管理。
|
系统 SHALL 将跨路由共享的响应格式化函数抽取到 helpers.ts 模块,单一职责、集中管理。
|
||||||
@@ -43,36 +43,17 @@
|
|||||||
- **WHEN** 需要将 StoredCheckResult 映射为 API CheckResult
|
- **WHEN** 需要将 StoredCheckResult 映射为 API CheckResult
|
||||||
- **THEN** 从 `helpers.ts` 导入 `mapCheckResult` 函数,处理 failure JSON 解析和格式转换
|
- **THEN** 从 `helpers.ts` 导入 `mapCheckResult` 函数,处理 failure JSON 解析和格式转换
|
||||||
|
|
||||||
### Requirement: 参数校验逻辑抽取为中间件
|
|
||||||
系统 SHALL 将重复的参数校验逻辑(target ID 解析、时间范围校验、分页参数校验、方法检查)抽取到 middleware.ts 模块。
|
|
||||||
|
|
||||||
#### Scenario: 方法检查中间件
|
|
||||||
- **WHEN** 请求方法不是 GET 或 HEAD
|
|
||||||
- **THEN** `guardGetHead(request, mode)` SHALL 返回 405 Response,否则返回 null 表示放行
|
|
||||||
|
|
||||||
#### Scenario: Target ID 校验
|
|
||||||
- **WHEN** URL 中的 id 参数不是正整数
|
|
||||||
- **THEN** `validateTargetId(idStr)` SHALL 返回 400 ApiError
|
|
||||||
|
|
||||||
#### Scenario: 时间范围参数校验
|
|
||||||
- **WHEN** from 或 to 参数缺失或格式无效
|
|
||||||
- **THEN** `validateTimeRange(from, to)` SHALL 返回 400 ApiError
|
|
||||||
|
|
||||||
#### Scenario: 分页参数校验
|
|
||||||
- **WHEN** page 或 pageSize 参数不是正整数
|
|
||||||
- **THEN** `validatePagination(page, pageSize)` SHALL 返回 400 ApiError
|
|
||||||
|
|
||||||
### Requirement: 静态资源服务独立管理
|
### Requirement: 静态资源服务独立管理
|
||||||
系统 SHALL 将静态资源服务、SPA fallback 和 Content-Type 映射逻辑抽取到 static.ts 模块。
|
系统 SHALL 将 SPA fallback 逻辑交给 routes 对象中的 HTML import 通配符处理,静态资源服务由 Bun 内置 manifest 机制自动处理。
|
||||||
|
|
||||||
#### Scenario: 根路径返回 index.html
|
#### Scenario: 根路径返回 index.html
|
||||||
- **WHEN** 客户端请求 `/`
|
- **WHEN** 客户端请求 `/`
|
||||||
- **THEN** `static.ts` 的 handler 返回 index.html,设置正确的 Content-Type 和 Cache-Control
|
- **THEN** routes 中注册的 HTML import 自动返回 index.html
|
||||||
|
|
||||||
#### Scenario: 资源文件返回正确 Content-Type
|
#### Scenario: 资源文件返回正确 Content-Type
|
||||||
- **WHEN** 客户端请求 `/assets/main.js`
|
- **WHEN** 客户端请求构建后的静态资源
|
||||||
- **THEN** `static.ts` 的 handler 根据文件扩展名返回正确的 Content-Type(如 `.js` → `text/javascript`)
|
- **THEN** Bun 内置 manifest 机制自动返回正确的 Content-Type 和缓存头
|
||||||
|
|
||||||
#### Scenario: SPA fallback
|
#### Scenario: SPA fallback
|
||||||
- **WHEN** 客户端请求非 API、非资源的路径(如 `/dashboard`)
|
- **WHEN** 客户端请求非 API、非资源的路径(如 `/dashboard`)
|
||||||
- **THEN** `static.ts` 的 handler 返回 index.html 实现 SPA 的客户端路由
|
- **THEN** routes 中注册的 `"/*"` HTML import 通配符返回 index.html 实现 SPA 的客户端路由
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
||||||
|
|
||||||
### Requirement: 批量查询目标统计
|
### Requirement: 批量查询目标统计
|
||||||
系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。
|
系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计(totalChecks 和 availability)。availability 计算精度 SHALL 与 `getTargetStats` 一致,统一使用 `Math.round(value * 100) / 100` 保留两位小数。
|
||||||
|
|
||||||
#### Scenario: 获取所有目标的聚合统计
|
#### Scenario: 获取所有目标的聚合统计
|
||||||
- **WHEN** 调用 `getAllTargetStats()`
|
- **WHEN** 调用 `getAllTargetStats()`
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
#### Scenario: availability 精度
|
#### Scenario: availability 精度
|
||||||
- **WHEN** 计算 availability(upCount / totalChecks * 100)
|
- **WHEN** 计算 availability(upCount / totalChecks * 100)
|
||||||
- **THEN** 结果 SHALL 四舍五入保留两位小数
|
- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致
|
||||||
|
|
||||||
### Requirement: summary 查询使用批量方法
|
### Requirement: summary 查询使用批量方法
|
||||||
`getSummary` 方法 SHALL 使用 `getLatestChecksMap` 一次性获取所有 target 的最新检查结果,而非对每个 target 逐条查询。
|
`getSummary` 方法 SHALL 使用 `getLatestChecksMap` 一次性获取所有 target 的最新检查结果,而非对每个 target 逐条查询。
|
||||||
@@ -39,11 +39,30 @@
|
|||||||
- **THEN** 系统 SHALL 调用 `getLatestChecksMap()` 一次获取所有最新结果,在内存中遍历统计 up/down 数量,而非循环 N 次调用 `getLatestCheck()`
|
- **THEN** 系统 SHALL 调用 `getLatestChecksMap()` 一次获取所有最新结果,在内存中遍历统计 up/down 数量,而非循环 N 次调用 `getLatestCheck()`
|
||||||
|
|
||||||
### Requirement: targets 列表使用批量方法
|
### Requirement: targets 列表使用批量方法
|
||||||
`createTargetsResponse`(app.ts 中生成 TargetStatus[] 的逻辑)SHALL 使用 `getLatestChecksMap` 和 `getAllTargetStats` 替代逐目标查询 latest checkout、stats 和 samples。
|
`handleTargets`(routes/targets.ts 中生成 TargetStatus[] 的逻辑)SHALL 使用 `getLatestChecksMap`、`getAllTargetStats` 和 `getAllRecentSamples` 替代逐目标查询,消除 N+1 查询。
|
||||||
|
|
||||||
#### Scenario: 目标列表使用批量查询
|
#### Scenario: 目标列表使用批量查询
|
||||||
- **WHEN** 处理 `GET /api/targets` 请求
|
- **WHEN** 处理 `GET /api/targets` 请求
|
||||||
- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()`、`getAllTargetStats()` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库
|
- **THEN** 系统 SHALL 分别调用 `getLatestChecksMap()`、`getAllTargetStats()`、`getAllRecentSamples(30)` 批量获取数据,在内存中组装 TargetStatus 数组,而非对每个 target 逐条查询数据库
|
||||||
|
|
||||||
|
#### Scenario: 目标无采样数据
|
||||||
|
- **WHEN** 某 target 在 getAllRecentSamples 返回的 Map 中不存在
|
||||||
|
- **THEN** 该 target 的 recentSamples SHALL 为空数组
|
||||||
|
|
||||||
|
### Requirement: 批量查询所有目标的最近采样数据
|
||||||
|
系统 SHALL 提供 `getAllRecentSamples(limit: number)` 方法,通过单次 SQL 查询获取所有 target 的最近 N 条采样数据,返回 `Map<number, Array<{ timestamp: string; duration_ms: number | null; matched: number }>>` 结构。
|
||||||
|
|
||||||
|
#### Scenario: 获取所有目标的最近采样
|
||||||
|
- **WHEN** 调用 `getAllRecentSamples(30)`
|
||||||
|
- **THEN** 系统 SHALL 通过单次 SQL 查询获取每个 target 最近 30 条记录,返回按 target_id 索引的 Map
|
||||||
|
|
||||||
|
#### Scenario: 目标无历史记录
|
||||||
|
- **WHEN** 某 target 在 check_results 表中无任何记录
|
||||||
|
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
|
||||||
|
|
||||||
|
#### Scenario: 采样数据排序
|
||||||
|
- **WHEN** 获取采样数据
|
||||||
|
- **THEN** 每个 target 的记录 SHALL 按 timestamp 降序排列(最新在前)
|
||||||
|
|
||||||
### Requirement: prepared statement 使用 query() 缓存
|
### Requirement: prepared statement 使用 query() 缓存
|
||||||
ProbeStore 中不涉及事务内复用的单次读/写操作 SHALL 使用 `this.db.query()` 而非 `this.db.prepare()`,利用 bun:sqlite 内置的 statement 缓存机制。
|
ProbeStore 中不涉及事务内复用的单次读/写操作 SHALL 使用 `this.db.query()` 而非 `this.db.prepare()`,利用 bun:sqlite 内置的 statement 缓存机制。
|
||||||
|
|||||||
49
openspec/specs/bun-fullstack-routing/spec.md
Normal file
49
openspec/specs/bun-fullstack-routing/spec.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
定义基于 Bun.serve `routes` 对象的全栈声明式路由注册、路径参数、HTTP method 声明和 fallback 行为。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 声明式路由注册
|
||||||
|
系统 SHALL 使用 Bun.serve 的 `routes` 对象以声明式方式注册所有 HTTP 路由,包括 HTML 页面路由和 API 端点路由。
|
||||||
|
|
||||||
|
#### Scenario: HTML 页面路由注册
|
||||||
|
- **WHEN** server 启动时
|
||||||
|
- **THEN** 系统 SHALL 通过 HTML import 将前端入口注册到 `routes` 对象的 `"/*"` 通配符路径
|
||||||
|
|
||||||
|
#### Scenario: API 端点路由注册
|
||||||
|
- **WHEN** server 启动时
|
||||||
|
- **THEN** 系统 SHALL 将所有 API 端点以 method handler 对象形式注册到 `routes` 对象
|
||||||
|
|
||||||
|
### Requirement: 路径参数支持
|
||||||
|
系统 SHALL 使用 routes 对象的 `:param` 语法声明路径参数,替代手动 regex 匹配。
|
||||||
|
|
||||||
|
#### Scenario: 带路径参数的 API 路由
|
||||||
|
- **WHEN** 客户端请求 `/api/targets/123/history`
|
||||||
|
- **THEN** 系统 SHALL 通过 `routes` 中注册的 `/api/targets/:id/history` 匹配,并通过 `req.params.id` 获取参数值 `"123"`
|
||||||
|
|
||||||
|
#### Scenario: 路径参数类型
|
||||||
|
- **WHEN** route handler 接收到路径参数
|
||||||
|
- **THEN** 参数值 SHALL 为字符串类型,handler 负责进行类型转换和校验
|
||||||
|
|
||||||
|
### Requirement: HTTP Method 声明
|
||||||
|
系统 SHALL 在 routes 对象中为每个 API 端点以 per-method handler 形式声明支持的 HTTP method;未匹配 method 的 API 请求 SHALL 落入 `/api/*` 通配符并返回 JSON 404。
|
||||||
|
|
||||||
|
#### Scenario: 单 method 端点
|
||||||
|
- **WHEN** API 端点只支持 GET 方法
|
||||||
|
- **THEN** 该端点 SHALL 以 `{ GET(req) { ... } }` 形式注册
|
||||||
|
|
||||||
|
#### Scenario: 不支持的 method 请求
|
||||||
|
- **WHEN** 客户端使用未声明的 method 请求 API 端点
|
||||||
|
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 格式的 404 错误响应
|
||||||
|
|
||||||
|
### Requirement: Fetch Fallback 处理
|
||||||
|
系统 SHALL 使用 `fetch` handler 作为兜底,理论上不应被触发(所有路径都被 routes 通配符覆盖)。
|
||||||
|
|
||||||
|
#### Scenario: 未匹配的 API 路由
|
||||||
|
- **WHEN** 请求路径以 `/api/` 开头但未在具体 API 路由中注册
|
||||||
|
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 格式的 404 错误响应
|
||||||
|
|
||||||
|
#### Scenario: 未匹配的非 API 路由
|
||||||
|
- **WHEN** 请求路径不以 `/api/` 开头且未在具体路由中注册
|
||||||
|
- **THEN** `"/*": homepage` 通配符 SHALL 返回前端入口 HTML 文档(带 HMR 注入)
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
- **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串
|
- **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串
|
||||||
|
|
||||||
### Requirement: Checker 接口定义
|
### Requirement: Checker 接口定义
|
||||||
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的 `CheckerDefinition`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。`CheckerContext` SHALL 包含引擎注入的 `AbortSignal`。接口方法的参数和返回值 SHALL 使用 base interface 类型(`RawTargetConfig`、`ResolvedTargetBase`),各 checker 实现内部自行 narrow 到具体类型。
|
系统 SHALL 在 `src/server/checker/runner/types.ts` 中定义面向扩展的泛型 `CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase>`,包含 `type`、`configKey`、TypeBox 配置契约、启动期语义校验、`resolve`、`execute`、`serialize` 成员。泛型参数 SHALL 约束 `execute` 和 `serialize` 方法的 target 参数类型,使 checker 实现内部获得编译期类型安全。默认泛型参数 `= ResolvedTargetBase` 保证中间层(registry、engine、config-loader)无需指定泛型。
|
||||||
|
|
||||||
#### Scenario: Checker 接口包含必要方法
|
#### Scenario: Checker 接口包含必要方法
|
||||||
- **WHEN** 开发者实现一个新的 Checker
|
- **WHEN** 开发者实现一个新的 Checker
|
||||||
@@ -68,20 +68,24 @@
|
|||||||
- **WHEN** checker 定义 `type: "tcp"`
|
- **WHEN** checker 定义 `type: "tcp"`
|
||||||
- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组
|
- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组
|
||||||
|
|
||||||
#### Scenario: 接口方法使用 base 类型
|
#### Scenario: 接口方法使用泛型约束
|
||||||
- **WHEN** 开发者查看 `CheckerDefinition` 接口签名
|
- **WHEN** 开发者查看 `CheckerDefinition<TResolved>` 接口签名
|
||||||
- **THEN** `resolve` 的参数 SHALL 为 `RawTargetConfig`,返回值 SHALL 为 `ResolvedTargetBase`;`execute` 的参数 SHALL 为 `ResolvedTargetBase`;`serialize` 的参数 SHALL 为 `ResolvedTargetBase`
|
- **THEN** `resolve` 的返回值 SHALL 为 `TResolved`;`execute` 的参数 SHALL 为 `TResolved`;`serialize` 的参数 SHALL 为 `TResolved`
|
||||||
|
|
||||||
#### Scenario: checker 实现内部 narrow
|
#### Scenario: checker 实现无需手动断言
|
||||||
- **WHEN** HttpChecker 的 execute 方法接收 `ResolvedTargetBase` 参数
|
- **WHEN** HttpChecker 实现 `CheckerDefinition<ResolvedHttpTarget>`
|
||||||
- **THEN** 方法内部 SHALL 将参数 narrow 为 `ResolvedHttpTarget`(通过 type assertion),然后使用具体类型的字段
|
- **THEN** `execute` 方法的 target 参数类型 SHALL 直接为 `ResolvedHttpTarget`,无需在方法内部使用 `as` 类型断言
|
||||||
|
|
||||||
|
#### Scenario: registry 使用默认泛型参数
|
||||||
|
- **WHEN** CheckerRegistry 存储和返回 checker 实例
|
||||||
|
- **THEN** registry 内部 SHALL 使用 `CheckerDefinition`(等价于 `CheckerDefinition<ResolvedTargetBase>`),实现类型擦除
|
||||||
|
|
||||||
### Requirement: CheckerRegistry 注册中心
|
### Requirement: CheckerRegistry 注册中心
|
||||||
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。
|
系统 SHALL 在 `src/server/checker/runner/registry.ts` 中提供 `CheckerRegistry` 类,支持 `register(checker)`、`get(type)` 和 `supportedTypes`。重复注册同一 type SHALL 抛出错误。registry 内部 SHALL 存储 `CheckerDefinition`(使用默认泛型参数),对外提供类型擦除后的接口。
|
||||||
|
|
||||||
#### Scenario: 注册并获取 Checker
|
#### Scenario: 注册并获取 Checker
|
||||||
- **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")`
|
- **WHEN** 调用 `registry.register(new HttpChecker())` 后再调用 `registry.get("http")`
|
||||||
- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例
|
- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例(类型为 `CheckerDefinition`)
|
||||||
|
|
||||||
#### Scenario: 获取未注册的 type
|
#### Scenario: 获取未注册的 type
|
||||||
- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker
|
- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
定义项目代码质量门禁、格式化检查、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产 executable 行为。
|
定义项目代码质量门禁、格式化检查、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产构建。
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -108,11 +108,11 @@
|
|||||||
- **THEN** `check` MUST 以非零状态退出且不静默忽略失败
|
- **THEN** `check` MUST 以非零状态退出且不静默忽略失败
|
||||||
|
|
||||||
### Requirement: 完整验证命令
|
### Requirement: 完整验证命令
|
||||||
项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产 executable 行为。
|
项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产构建。原 executable smoke test 暂时移除,后续通过独立变更重新设计。
|
||||||
|
|
||||||
#### Scenario: 运行完整验证
|
#### Scenario: 运行完整验证
|
||||||
- **WHEN** 开发者运行 `bun run verify`
|
- **WHEN** 开发者运行 `bun run verify`
|
||||||
- **THEN** 系统 SHALL 先运行 `check`,再运行生产构建和 executable smoke test
|
- **THEN** 系统 SHALL 先运行 `check`,再运行生产构建
|
||||||
|
|
||||||
#### Scenario: 完整验证失败
|
#### Scenario: 完整验证失败
|
||||||
- **WHEN** `verify` 中任一阶段失败
|
- **WHEN** `verify` 中任一阶段失败
|
||||||
|
|||||||
@@ -117,15 +117,27 @@
|
|||||||
- **THEN** 系统 SHALL 判定 matched 为 false
|
- **THEN** 系统 SHALL 判定 matched 为 false
|
||||||
|
|
||||||
### Requirement: 结构化 expect 失败信息
|
### Requirement: 结构化 expect 失败信息
|
||||||
系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。
|
系统 SHALL 在任一 expect 规则失败时生成结构化 failure,用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符,超出部分以省略标记和总字符数替代。expected 值不截断。
|
||||||
|
|
||||||
#### Scenario: body 规则失败信息
|
#### Scenario: body 规则失败信息
|
||||||
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
|
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
|
||||||
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
|
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
|
||||||
|
|
||||||
#### Scenario: actual 值截断
|
#### Scenario: actual 值截断
|
||||||
- **WHEN** 失败规则的实际值超过系统允许记录的摘要长度
|
- **WHEN** 失败规则的实际值为字符串且长度超过 200 字符
|
||||||
- **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出
|
- **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数
|
||||||
|
|
||||||
|
#### Scenario: actual 值未超限
|
||||||
|
- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符
|
||||||
|
- **THEN** failure.actual SHALL 保留完整原始值,不做截断
|
||||||
|
|
||||||
|
#### Scenario: actual 值为对象或数组
|
||||||
|
- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符
|
||||||
|
- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀
|
||||||
|
|
||||||
|
#### Scenario: actual 值为标量
|
||||||
|
- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined
|
||||||
|
- **THEN** failure.actual SHALL 保留原始值,不做截断
|
||||||
|
|
||||||
### Requirement: 状态码范围匹配
|
### Requirement: 状态码范围匹配
|
||||||
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"`、`"2xx"`、`"3xx"`、`"4xx"`、`"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。
|
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"`、`"2xx"`、`"3xx"`、`"4xx"`、`"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。
|
||||||
@@ -163,10 +175,10 @@
|
|||||||
- **THEN** 系统 SHALL 在启动期配置校验失败
|
- **THEN** 系统 SHALL 在启动期配置校验失败
|
||||||
|
|
||||||
### Requirement: HTTP expect 规则启动期校验
|
### Requirement: HTTP expect 规则启动期校验
|
||||||
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operator;body 提取规则可以不配置 operator,并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value,包括数组和对象。
|
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operator;body 提取规则可以不配置 operator,并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value,包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。
|
||||||
|
|
||||||
#### Scenario: body rule 使用 regex 字段
|
#### Scenario: body rule 使用 regex 字段
|
||||||
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译
|
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
|
||||||
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
|
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
|
||||||
|
|
||||||
#### Scenario: body rule 不支持 match 字段
|
#### Scenario: body rule 不支持 match 字段
|
||||||
@@ -225,6 +237,18 @@
|
|||||||
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
|
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
|
||||||
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
|
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
|
||||||
|
|
||||||
|
#### Scenario: regex body 规则含嵌套量词启动失败
|
||||||
|
- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]`
|
||||||
|
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
|
||||||
|
|
||||||
|
#### Scenario: match operator 含嵌套量词启动失败
|
||||||
|
- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}`
|
||||||
|
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
|
||||||
|
|
||||||
|
#### Scenario: 安全正则通过校验
|
||||||
|
- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]`
|
||||||
|
- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险)
|
||||||
|
|
||||||
### Requirement: HTTP body 运行期失败结构化
|
### Requirement: HTTP body 运行期失败结构化
|
||||||
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure,并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch;响应内容无法按配置解析或解码 SHALL 记录为 error。
|
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure,并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch;响应内容无法按配置解析或解码 SHALL 记录为 error。
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,41 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
定义 Vite + React + TypeScript 前端开发工作流、开发期 API 代理和共享契约的行为要求。
|
定义 Bun.serve fullstack + React + TypeScript 前端开发工作流、开发期 API 访问和共享契约的行为要求。
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Vite React 开发服务器
|
### Requirement: Vite React 开发服务器
|
||||||
系统 SHALL 提供基于 Vite + React + TypeScript 的前端开发工作流,并支持热模块替换。
|
系统 SHALL 提供基于 Bun.serve fullstack 模式的前端开发工作流,并支持热模块替换和 React Fast Refresh。
|
||||||
|
|
||||||
#### Scenario: 启动前端开发服务器
|
#### Scenario: 启动前端开发服务器
|
||||||
- **WHEN** 开发者启动前端开发命令
|
- **WHEN** 开发者启动开发命令
|
||||||
- **THEN** 前端 SHALL 由 Vite 提供服务,并启用 React 热模块替换
|
- **THEN** 前端 SHALL 由 Bun.serve 的 HTML import 机制提供服务,并通过 `development: { hmr: true, console: true }` 启用 HMR、React Fast Refresh 和浏览器 console 回显
|
||||||
|
|
||||||
#### Scenario: 构建前端静态资源
|
#### Scenario: 构建前端静态资源
|
||||||
- **WHEN** 开发者运行前端生产构建命令
|
- **WHEN** 开发者运行前端生产构建命令
|
||||||
- **THEN** 系统 SHALL 产出可由 Bun 后端服务的前端静态资源
|
- **THEN** 系统 SHALL 通过 Bun.build 的 HTML import ahead-of-time bundling 产出可由 Bun 后端服务的前端静态资源
|
||||||
|
|
||||||
### Requirement: 前端开发期 API 代理
|
### Requirement: 前端开发期 API 代理
|
||||||
前端开发服务器 SHALL 在本地开发期间将 `/api/*` 请求代理到 Bun 后端服务。
|
前端开发服务器 SHALL 在本地开发期间无需代理配置即可访问后端 API,因为前后端运行在同一进程同一端口。
|
||||||
|
|
||||||
#### Scenario: 前端开发期调用拨测 API
|
#### Scenario: 前端开发期调用拨测 API
|
||||||
- **WHEN** 浏览器从 Vite 开发源请求 `/api/summary`、`/api/targets` 等拨测 API
|
- **WHEN** 浏览器从开发服务器请求 `/api/summary`、`/api/targets` 等拨测 API
|
||||||
- **THEN** Vite SHALL 将请求转发到 Bun 后端服务,且不需要浏览器 CORS 配置
|
- **THEN** Bun.serve SHALL 直接由 routes 中注册的 API handler 处理请求,无需 proxy 转发
|
||||||
|
|
||||||
#### Scenario: 开发期访问非 API 前端路由
|
#### Scenario: 开发期访问非 API 前端路由
|
||||||
- **WHEN** 浏览器从 Vite 开发源请求非 API 前端路由
|
- **WHEN** 浏览器从开发服务器请求非 API 前端路由
|
||||||
- **THEN** Vite SHALL 将该请求作为前端应用流量处理,而不是转发到后端
|
- **THEN** Bun.serve SHALL 将该请求作为前端应用流量处理(SPA fallback 返回 HTML)
|
||||||
|
|
||||||
### Requirement: 开发期后端端口一致性
|
### Requirement: 开发期单端口运行
|
||||||
项目 SHALL 保证文档化的全栈开发命令中,Vite proxy 目标端口与 Bun 后端监听端口来自同一配置来源。
|
项目 SHALL 保证开发命令中前端页面、HMR 和后端 API 由同一个 Bun.serve 进程在同一端口提供服务。
|
||||||
|
|
||||||
#### Scenario: 使用默认开发端口
|
#### Scenario: 使用默认开发端口
|
||||||
- **WHEN** 开发者未提供端口覆盖并运行文档化的全栈开发命令
|
- **WHEN** 开发者未提供端口覆盖并运行开发命令
|
||||||
- **THEN** Bun 后端 SHALL 监听默认端口,且 Vite SHALL 将 `/api/*` 代理到同一端口
|
- **THEN** Bun.serve SHALL 在默认端口同时提供前端页面、HMR 和后端 API
|
||||||
|
|
||||||
#### Scenario: 使用 PORT 覆盖开发端口
|
#### Scenario: 使用配置覆盖开发端口
|
||||||
- **WHEN** 开发者通过 `PORT` 覆盖后端端口并运行文档化的全栈开发命令
|
- **WHEN** 开发者通过配置文件覆盖端口并运行开发命令
|
||||||
- **THEN** Bun 后端 SHALL 监听该端口,且 Vite SHALL 将 `/api/*` 代理到同一端口
|
- **THEN** Bun.serve SHALL 在配置端口同时提供前端页面、HMR 和后端 API
|
||||||
|
|
||||||
#### Scenario: 避免代理端口与后端端口分叉
|
|
||||||
- **WHEN** 开发期脚本需要向 Vite 传递后端端口
|
|
||||||
- **THEN** 该代理端口 MUST 从文档化的后端端口配置派生,而不是作为独立对外配置导致分叉
|
|
||||||
|
|
||||||
### Requirement: 前端使用相对 API 路径
|
### Requirement: 前端使用相对 API 路径
|
||||||
除非有文档化的部署配置覆盖该行为,前端代码 MUST 通过相对 `/api/*` URL 调用后端 API。
|
除非有文档化的部署配置覆盖该行为,前端代码 MUST 通过相对 `/api/*` URL 调用后端 API。
|
||||||
@@ -57,7 +53,7 @@
|
|||||||
|
|
||||||
#### Scenario: 启动全栈开发
|
#### Scenario: 启动全栈开发
|
||||||
- **WHEN** 开发者运行文档化的全栈开发命令
|
- **WHEN** 开发者运行文档化的全栈开发命令
|
||||||
- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 Bun 后端服务器
|
- **THEN** 系统 SHALL 启动单个 Bun.serve 进程,同时提供前端 HMR 和后端 API 服务
|
||||||
|
|
||||||
### Requirement: 开发质量命令文档化
|
### Requirement: 开发质量命令文档化
|
||||||
项目 SHALL 在前端开发工作流文档中说明日常检查和完整验证命令。
|
项目 SHALL 在前端开发工作流文档中说明日常检查和完整验证命令。
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Bun HTTP 运行时
|
### Requirement: Bun HTTP 运行时
|
||||||
系统 SHALL 运行一个 Bun HTTP server,由单个进程提供后端 API、健康检查、生产静态资源和 SPA fallback 行为。
|
系统 SHALL 运行一个 Bun HTTP server,使用 `routes` 对象声明式注册 HTML 页面路由和 API 端点,由单个进程提供后端 API、健康检查和前端服务。
|
||||||
|
|
||||||
#### Scenario: 启动运行时服务器
|
#### Scenario: 启动运行时服务器
|
||||||
- **WHEN** server 进程成功启动
|
- **WHEN** server 进程成功启动
|
||||||
- **THEN** 它 SHALL 监听 YAML 配置文件中指定的 host 和 port,并记录实际 server URL
|
- **THEN** 它 SHALL 监听配置文件中指定的 host 和 port,通过 routes 对象注册所有路由,并记录实际 server URL
|
||||||
|
|
||||||
#### Scenario: 通过 YAML 配置提供运行时参数
|
#### Scenario: 通过 YAML 配置提供运行时参数
|
||||||
- **WHEN** 通过 YAML 配置文件提供 host、port、数据目录等参数
|
- **WHEN** 通过 YAML 配置文件提供 host、port、数据目录等参数
|
||||||
@@ -21,22 +21,18 @@
|
|||||||
|
|
||||||
#### Scenario: 提供拨测相关 API
|
#### Scenario: 提供拨测相关 API
|
||||||
- **WHEN** server 启动完成
|
- **WHEN** server 启动完成
|
||||||
- **THEN** 系统 SHALL 提供 `/api/summary`、`/api/targets`、`/api/targets/:id/history`、`/api/targets/:id/trend` 端点
|
- **THEN** 系统 SHALL 通过 routes 对象提供 `/api/summary`、`/api/targets`、`/api/targets/:id/history`、`/api/targets/:id/trend` 端点
|
||||||
|
|
||||||
### Requirement: HTTP method 语义
|
### Requirement: HTTP method 语义
|
||||||
系统 SHALL 为运行时端点提供明确的 HTTP method 语义,避免不支持的 method 被错误地当作成功请求处理。
|
系统 SHALL 只为运行时端点声明实际支持的 GET handler;不支持的 API method SHALL 按未匹配 API 路由处理,不再保留自定义 405 和 Allow header 语义。
|
||||||
|
|
||||||
#### Scenario: GET 请求访问运行时端点
|
#### Scenario: GET 请求访问运行时端点
|
||||||
- **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/*` 端点
|
- **WHEN** 客户端使用 `GET` 请求 `/health` 或 `/api/*` 端点
|
||||||
- **THEN** Bun server SHALL 返回对应端点的成功响应
|
- **THEN** Bun server SHALL 返回对应端点的成功响应
|
||||||
|
|
||||||
#### Scenario: HEAD 请求访问运行时端点
|
#### Scenario: 不支持的 API method 请求
|
||||||
- **WHEN** 客户端使用 `HEAD` 请求 `/health` 或 `/api/*` 端点
|
- **WHEN** 客户端使用不支持的 method 请求已存在的 `/api/*` 端点
|
||||||
- **THEN** Bun server SHALL 返回与 `GET` 相同的成功状态和 headers,但 MUST NOT 返回响应体
|
- **THEN** `/api/*` 通配符 SHALL 返回包含 `error` 和 `status` 字段的 JSON 404 响应
|
||||||
|
|
||||||
#### Scenario: 不支持的 method 访问运行时端点
|
|
||||||
- **WHEN** 客户端使用不支持的 method 请求 `/health` 或 `/api/*` 端点
|
|
||||||
- **THEN** Bun server SHALL 返回 405 状态码和 Allow header
|
|
||||||
|
|
||||||
### Requirement: API 路由命名空间
|
### Requirement: API 路由命名空间
|
||||||
系统 MUST 将 `/api/*` 保留给后端 API 路由。
|
系统 MUST 将 `/api/*` 保留给后端 API 路由。
|
||||||
@@ -50,15 +46,15 @@
|
|||||||
- **THEN** Bun server MUST 返回 JSON 404 响应,而不是前端 HTML 文档
|
- **THEN** Bun server MUST 返回 JSON 404 响应,而不是前端 HTML 文档
|
||||||
|
|
||||||
### Requirement: API 错误响应一致性
|
### Requirement: API 错误响应一致性
|
||||||
系统 SHALL 为 API 命名空间内的错误返回机器可读 JSON 响应。
|
系统 SHALL 为 API 命名空间内的未匹配路由和未匹配 method 返回机器可读 JSON 404 响应。
|
||||||
|
|
||||||
#### Scenario: 未知 API 路由
|
#### Scenario: 未知 API 路由
|
||||||
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
||||||
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 404 响应,而不是前端 HTML 文档
|
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 404 响应,而不是前端 HTML 文档
|
||||||
|
|
||||||
#### Scenario: API method 不允许
|
#### Scenario: API method 不匹配
|
||||||
- **WHEN** 客户端使用不支持的 method 请求已存在的 API 路由
|
- **WHEN** 客户端使用不支持的 method 请求已存在的 API 路由
|
||||||
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 405 响应
|
- **THEN** Bun server MUST 返回包含 `error` 和 `status` 字段的 JSON 404 响应
|
||||||
|
|
||||||
### Requirement: 健康检查端点
|
### Requirement: 健康检查端点
|
||||||
系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。
|
系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。
|
||||||
@@ -68,56 +64,48 @@
|
|||||||
- **THEN** Bun server SHALL 返回成功的、机器可读的健康检查响应
|
- **THEN** Bun server SHALL 返回成功的、机器可读的健康检查响应
|
||||||
|
|
||||||
### Requirement: 生产静态资源服务
|
### Requirement: 生产静态资源服务
|
||||||
系统 SHALL 在生产模式下由 Bun runtime 服务 Vite 生产资源。
|
系统 SHALL 在生产模式下通过 Bun 内置的 HTML import manifest 机制服务前端资源。
|
||||||
|
|
||||||
#### Scenario: 请求构建后的资源
|
#### Scenario: 请求构建后的资源
|
||||||
- **WHEN** 客户端请求构建后的前端资源,例如 `/assets/app.js`
|
- **WHEN** 客户端请求构建后的前端资源
|
||||||
- **THEN** Bun server SHALL 返回该资源并带有适当的 content type
|
- **THEN** Bun server SHALL 通过 manifest 自动返回该资源并带有适当的 content type 和 content-addressable hash URL
|
||||||
|
|
||||||
#### Scenario: 请求前端根路径
|
#### Scenario: 请求前端根路径
|
||||||
- **WHEN** 客户端请求 `/`
|
- **WHEN** 客户端请求 `/`
|
||||||
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
|
- **THEN** Bun server SHALL 通过 routes 中注册的 HTML import 返回前端入口 HTML 文档
|
||||||
|
|
||||||
### Requirement: 生产缓存策略
|
### Requirement: 生产缓存策略
|
||||||
系统 SHALL 为生产静态资源和前端入口 HTML 使用明确的缓存策略。
|
系统 SHALL 利用 Bun 内置的缓存机制为生产静态资源提供缓存策略。
|
||||||
|
|
||||||
#### Scenario: 请求前端入口 HTML
|
#### Scenario: 请求前端入口 HTML
|
||||||
- **WHEN** 生产 Bun server 返回前端入口 HTML 文档
|
- **WHEN** 生产 Bun server 返回前端入口 HTML 文档
|
||||||
- **THEN** 响应 SHALL 使用 `Cache-Control: no-cache`
|
- **THEN** 响应 SHALL 包含 Bun 自动生成的 ETag header
|
||||||
|
|
||||||
#### Scenario: 请求构建后的静态资源
|
#### Scenario: 请求构建后的静态资源
|
||||||
- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源
|
- **WHEN** 生产 Bun server 返回构建后的静态资源
|
||||||
- **THEN** 响应 SHALL 使用长缓存策略 `public, max-age=31536000, immutable`
|
- **THEN** 响应 SHALL 包含 Bun 自动生成的 ETag header 和 content-addressable hash URL
|
||||||
|
|
||||||
#### Scenario: 请求未知静态资源
|
|
||||||
- **WHEN** 客户端请求不存在的 `/assets/*` 资源或带文件扩展名的未知路径
|
|
||||||
- **THEN** Bun server MUST 返回 404,且 MUST NOT 返回前端入口 HTML 文档
|
|
||||||
|
|
||||||
### Requirement: 低风险安全响应头
|
### Requirement: 低风险安全响应头
|
||||||
系统 SHALL 在生产运行时响应中附加低风险安全响应头,提升基础安全性且不提前约束未来前端资源策略。
|
系统 SHALL 在生产运行时的 JSON API 响应中附加低风险安全响应头;HTML 和静态资源响应由 Bun HTML import manifest 返回其内置 headers。
|
||||||
|
|
||||||
#### Scenario: 生产 HTML 响应包含安全头
|
|
||||||
- **WHEN** 生产 Bun server 返回前端 HTML 文档
|
|
||||||
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
|
||||||
|
|
||||||
#### Scenario: 生产 JSON 响应包含安全头
|
#### Scenario: 生产 JSON 响应包含安全头
|
||||||
- **WHEN** 生产 Bun server 返回 `/health` 或 `/api/*` JSON 响应
|
- **WHEN** 生产 Bun server 返回 `/health` 或 `/api/*` JSON 响应
|
||||||
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
||||||
|
|
||||||
#### Scenario: 生产静态资源响应包含安全头
|
#### Scenario: 生产 HTML 和静态资源响应使用 Bun 内置 headers
|
||||||
- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源
|
- **WHEN** 生产 Bun server 返回前端 HTML 文档或构建后的静态资源
|
||||||
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff` 和 `Referrer-Policy` headers
|
- **THEN** 响应 SHALL 使用 Bun HTML import manifest 提供的内置 headers,不要求附加自定义安全 headers
|
||||||
|
|
||||||
### Requirement: SPA fallback 行为
|
### Requirement: SPA fallback 行为
|
||||||
系统 SHALL 在生产环境中为非 API、非静态资源的前端路由返回前端入口 HTML 文档。
|
系统 SHALL 通过 routes 中注册的 `"/*"` HTML import 通配符为非 API 路径返回前端入口 HTML 文档。
|
||||||
|
|
||||||
#### Scenario: 刷新前端路由
|
#### Scenario: 刷新前端路由
|
||||||
- **WHEN** 客户端请求前端路由,例如 `/dashboard`
|
- **WHEN** 客户端请求前端路由,例如 `/dashboard`
|
||||||
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
|
- **THEN** routes 中的 `"/*"` 通配符 SHALL 返回前端入口 HTML 文档
|
||||||
|
|
||||||
#### Scenario: 保留 API 错误语义
|
#### Scenario: 保留 API 错误语义
|
||||||
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
- **WHEN** 客户端请求未知的 `/api/*` 路由
|
||||||
- **THEN** Bun server MUST NOT 返回前端入口 HTML 文档
|
- **THEN** `/api/*` 通配符 MUST 返回 JSON 404 响应,而不是前端入口 HTML 文档
|
||||||
|
|
||||||
### Requirement: 优雅关机
|
### Requirement: 优雅关机
|
||||||
系统 SHALL 在收到终止信号时正确清理资源。
|
系统 SHALL 在收到终止信号时正确清理资源。
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
## ADDED Requirements
|
## Purpose
|
||||||
|
|
||||||
|
定义系统运行时元数据 API:checker 类型列表等元信息的对外暴露方式。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Meta 信息 API
|
### Requirement: Meta 信息 API
|
||||||
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。
|
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。未匹配 method SHALL 按 API 通配符处理为 JSON 404,不再保留自定义 HEAD/405 语义。
|
||||||
|
|
||||||
#### Scenario: 获取 checker 类型列表
|
#### Scenario: 获取 checker 类型列表
|
||||||
- **WHEN** 客户端请求 `GET /api/meta`
|
- **WHEN** 客户端请求 `GET /api/meta`
|
||||||
@@ -11,13 +15,9 @@
|
|||||||
- **WHEN** 系统启动并注册了 checker
|
- **WHEN** 系统启动并注册了 checker
|
||||||
- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致
|
- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致
|
||||||
|
|
||||||
#### Scenario: 仅允许 GET/HEAD 方法
|
#### Scenario: 不支持的 method 请求
|
||||||
- **WHEN** 客户端使用 POST/PUT/DELETE 等方法请求 `/api/meta`
|
- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta`
|
||||||
- **THEN** 系统 SHALL 返回 405 状态码
|
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应
|
||||||
|
|
||||||
#### Scenario: HEAD 请求返回空体
|
|
||||||
- **WHEN** 客户端使用 HEAD 方法请求 `/api/meta`
|
|
||||||
- **THEN** 系统 SHALL 返回 200 状态码和正确的 Content-Type header,body 为空
|
|
||||||
|
|
||||||
### Requirement: MetaResponse 共享类型
|
### Requirement: MetaResponse 共享类型
|
||||||
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。
|
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
- **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应
|
- **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应
|
||||||
|
|
||||||
### Requirement: API 错误处理
|
### Requirement: API 错误处理
|
||||||
系统 SHALL 对不存在的目标 ID 和无效参数返回适当的 HTTP 错误响应。
|
系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。
|
||||||
|
|
||||||
#### Scenario: 查询不存在的目标
|
#### Scenario: 查询不存在的目标
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/999/history`
|
- **WHEN** 客户端请求 `GET /api/targets/999/history`
|
||||||
@@ -104,6 +104,14 @@
|
|||||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc`
|
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc`
|
||||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||||
|
|
||||||
|
#### Scenario: pageSize 超过上限
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=201`
|
||||||
|
- **THEN** 系统 SHALL 返回 400 状态码和错误信息,提示 pageSize 不能超过 200
|
||||||
|
|
||||||
|
#### Scenario: pageSize 等于上限
|
||||||
|
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&pageSize=200`
|
||||||
|
- **THEN** 系统 SHALL 正常返回数据
|
||||||
|
|
||||||
#### Scenario: from 或 to 参数缺失
|
#### Scenario: from 或 to 参数缺失
|
||||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
|
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
|
||||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||||
@@ -122,3 +130,25 @@
|
|||||||
#### Scenario: 无失败信息
|
#### Scenario: 无失败信息
|
||||||
- **WHEN** 检查结果 matched=true
|
- **WHEN** 检查结果 matched=true
|
||||||
- **THEN** API SHALL 返回 failure 为 null
|
- **THEN** API SHALL 返回 failure 为 null
|
||||||
|
|
||||||
|
### Requirement: Meta 信息 API
|
||||||
|
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。未匹配 method SHALL 按 API 通配符处理为 JSON 404,不再保留自定义 HEAD/405 语义。
|
||||||
|
|
||||||
|
#### Scenario: 获取 checker 类型列表
|
||||||
|
- **WHEN** 客户端请求 `GET /api/meta`
|
||||||
|
- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`,包含所有已注册的 checker 类型标识符
|
||||||
|
|
||||||
|
#### Scenario: 类型列表来源
|
||||||
|
- **WHEN** 系统启动并注册了 checker
|
||||||
|
- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致
|
||||||
|
|
||||||
|
#### Scenario: 不支持的 method 请求
|
||||||
|
- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta`
|
||||||
|
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应
|
||||||
|
|
||||||
|
### Requirement: MetaResponse 共享类型
|
||||||
|
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。
|
||||||
|
|
||||||
|
#### Scenario: MetaResponse 类型定义
|
||||||
|
- **WHEN** 前后端引用 `MetaResponse` 类型
|
||||||
|
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段
|
||||||
|
|||||||
@@ -291,3 +291,18 @@
|
|||||||
#### Scenario: retention 字段缺省
|
#### Scenario: retention 字段缺省
|
||||||
- **WHEN** 配置文件中未指定 `runtime.retention`
|
- **WHEN** 配置文件中未指定 `runtime.retention`
|
||||||
- **THEN** 系统 SHALL 使用默认值 `"7d"`
|
- **THEN** 系统 SHALL 使用默认值 `"7d"`
|
||||||
|
|
||||||
|
### Requirement: 数据目录路径解析
|
||||||
|
配置加载流程 SHALL 将 `server.dataDir` 相对路径基于配置文件所在目录(configDir)解析为绝对路径。绝对路径 SHALL 保持不变。
|
||||||
|
|
||||||
|
#### Scenario: dataDir 为相对路径
|
||||||
|
- **WHEN** 配置文件位于 `/opt/dial/probes.yaml`,且 `server.dataDir` 配置为 `./data`
|
||||||
|
- **THEN** 系统 SHALL 将 dataDir 解析为 `/opt/dial/data`,而非依赖进程 cwd
|
||||||
|
|
||||||
|
#### Scenario: dataDir 为绝对路径
|
||||||
|
- **WHEN** `server.dataDir` 配置为 `/var/lib/dial/data`
|
||||||
|
- **THEN** 系统 SHALL 直接使用该绝对路径,不做额外解析
|
||||||
|
|
||||||
|
#### Scenario: dataDir 使用默认值
|
||||||
|
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`)
|
||||||
|
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径
|
||||||
|
|||||||
@@ -16,16 +16,24 @@
|
|||||||
- **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
|
- **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
|
||||||
|
|
||||||
### Requirement: 组内并发拨测
|
### Requirement: 组内并发拨测
|
||||||
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。
|
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。当某个目标的 checker 执行 rejected(非正常 CheckResult 返回,而是 Promise reject)时,系统 SHALL 将该异常记录为 `matched: false` 的 check_result,而非仅 console.warn。
|
||||||
|
|
||||||
#### Scenario: 同组目标并发执行
|
#### Scenario: 同组目标并发执行
|
||||||
- **WHEN** 调度器触发一次 tick,该组有 3 个目标,且全局并发余量至少为 3
|
- **WHEN** 调度器触发一次 tick,该组有 3 个目标,且全局并发余量至少为 3
|
||||||
- **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行
|
- **THEN** 系统 SHALL 同时执行 3 个 checker,而非顺序执行
|
||||||
|
|
||||||
#### Scenario: 单个目标失败不影响同组其他目标
|
#### Scenario: 单个目标失败不影响同组其他目标
|
||||||
- **WHEN** 同组中某个目标的检查请求超时或失败
|
- **WHEN** 同组中某个目标的检查请求超时或失败(checker 正常返回 CheckResult)
|
||||||
- **THEN** 其他目标的检查 SHALL 正常完成并记录结果
|
- **THEN** 其他目标的检查 SHALL 正常完成并记录结果
|
||||||
|
|
||||||
|
#### Scenario: 同组中某个目标的 checker 执行 rejected
|
||||||
|
- **WHEN** 同组中某个目标的 checker 执行抛出未捕获异常(Promise rejected)
|
||||||
|
- **THEN** 系统 SHALL 为该目标写入一条 `matched: false` 的 check_result,failure 为 `{ kind: "error", phase: "internal", path: "engine", message: <rejected reason> }`,其他目标的检查 SHALL 不受影响
|
||||||
|
|
||||||
|
#### Scenario: rejected 结果通过索引关联 targetName
|
||||||
|
- **WHEN** checker 执行 rejected
|
||||||
|
- **THEN** 系统 SHALL 通过 Promise.allSettled 的索引关联回 target 数组,获取对应的 targetName 用于写入 check_result
|
||||||
|
|
||||||
#### Scenario: 全局并发限制生效
|
#### Scenario: 全局并发限制生效
|
||||||
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
|
- **WHEN** 调度器同时触发 10 个目标且 runtime.maxConcurrentChecks 为 3
|
||||||
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
|
- **THEN** 系统 MUST 同时最多运行 3 个检查,其余检查等待并发槽位释放
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
## ADDED Requirements
|
## Purpose
|
||||||
|
|
||||||
|
TBD - 统一服务启动引导函数,封装开发和生产模式的完整启动序列。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
### Requirement: 统一启动引导函数
|
### Requirement: 统一启动引导函数
|
||||||
系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。
|
系统 SHALL 提供 `src/server/bootstrap.ts` 导出 `bootstrap(options: BootstrapOptions)` 函数,封装完整的服务启动序列:加载配置、创建 store、同步 targets、创建并启动 engine、启动 HTTP server、注册 shutdown handler。`bootstrap` SHALL 不接收或传递静态资源对象,前端资源由 Bun HTML import manifest 自动接管。
|
||||||
|
|
||||||
#### Scenario: 开发模式启动
|
#### Scenario: 开发模式启动
|
||||||
- **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`
|
- **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`
|
||||||
- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets
|
- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets
|
||||||
|
|
||||||
#### Scenario: 生产模式启动
|
#### Scenario: 生产模式启动
|
||||||
- **WHEN** build entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })`
|
- **WHEN** `main.ts` 调用 `bootstrap({ configPath, mode: "production" })`
|
||||||
- **THEN** 系统 SHALL 完成完整启动序列,并将 staticAssets 传递给 startServer
|
- **THEN** 系统 SHALL 完成完整启动序列,并由 `server.ts` 中的 HTML import 路由接管前端资源
|
||||||
|
|
||||||
#### Scenario: 启动失败处理
|
#### Scenario: 启动失败处理
|
||||||
- **WHEN** 启动过程中任何步骤抛出异常
|
- **WHEN** 启动过程中任何步骤抛出异常
|
||||||
@@ -20,19 +24,19 @@
|
|||||||
- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop() 和 store.close() 后退出
|
- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop() 和 store.close() 后退出
|
||||||
|
|
||||||
### Requirement: BootstrapOptions 接口
|
### Requirement: BootstrapOptions 接口
|
||||||
`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,包含 `configPath: string`、`mode: RuntimeMode`、`staticAssets?: StaticAssets`。
|
`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,仅包含 `configPath: string` 和 `mode: RuntimeMode`。
|
||||||
|
|
||||||
#### Scenario: 最小配置
|
#### Scenario: 最小配置
|
||||||
- **WHEN** 仅传入 configPath 和 mode
|
- **WHEN** 仅传入 configPath 和 mode
|
||||||
- **THEN** 系统 SHALL 正常启动,staticAssets 为 undefined
|
- **THEN** 系统 SHALL 正常启动
|
||||||
|
|
||||||
### Requirement: dev.ts 和 build entry 使用 bootstrap
|
### Requirement: dev.ts 和生产入口使用 bootstrap
|
||||||
`dev.ts` 和 `scripts/build.ts` 生成的 server entry SHALL 调用 `bootstrap()` 而非各自维护启动序列。
|
`dev.ts` 和 `src/server/main.ts` SHALL 调用 `bootstrap()` 而非各自维护启动序列。
|
||||||
|
|
||||||
#### Scenario: dev.ts 调用 bootstrap
|
#### Scenario: dev.ts 调用 bootstrap
|
||||||
- **WHEN** 开发者运行 `bun run dev:server`
|
- **WHEN** 开发者运行 `bun run dev`
|
||||||
- **THEN** `dev.ts` SHALL 调用 `bootstrap` 完成启动
|
- **THEN** `dev.ts` SHALL 调用 `bootstrap` 完成启动
|
||||||
|
|
||||||
#### Scenario: build entry 调用 bootstrap
|
#### Scenario: main.ts 调用 bootstrap
|
||||||
- **WHEN** 生产可执行文件启动
|
- **WHEN** 生产可执行文件启动
|
||||||
- **THEN** 生成的 entry SHALL 调用 `bootstrap` 完成启动
|
- **THEN** `main.ts` SHALL 调用 `bootstrap` 完成启动
|
||||||
@@ -1,61 +1,42 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
定义将 Vite 前端资源与 Bun 后端打包为单个 standalone executable 的生产构建、运行配置和验证要求。
|
定义将 Bun HTML import 前端资源与 Bun 后端打包为单个 standalone executable 的生产构建、运行配置和验证要求。
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: 生产构建顺序
|
### Requirement: 生产构建顺序
|
||||||
生产构建 MUST 在编译 Bun 后端 executable 之前先构建 Vite 前端。
|
生产构建 MUST 通过 Bun.build 的 HTML import 识别机制一步完成前端资源打包和后端编译。
|
||||||
|
|
||||||
#### Scenario: 运行生产构建
|
#### Scenario: 运行生产构建
|
||||||
- **WHEN** 开发者运行生产构建命令
|
- **WHEN** 开发者运行生产构建命令
|
||||||
- **THEN** 系统 MUST 在调用 Bun standalone executable 编译之前生成前端静态资源
|
- **THEN** 系统 MUST 调用 Bun.build,自动识别 server 入口中的 HTML import 并完成前端 bundling 和后端编译
|
||||||
|
|
||||||
#### Scenario: 前端构建失败
|
#### Scenario: 前端 bundling 失败
|
||||||
- **WHEN** 前端生产构建失败
|
- **WHEN** Bun.build 在处理 HTML import 中的前端资源时失败
|
||||||
- **THEN** 系统 MUST 停止生产构建,且不能输出 stale executable
|
- **THEN** 系统 MUST 停止生产构建,且不能输出 stale executable
|
||||||
|
|
||||||
### Requirement: 构建生成确定性
|
|
||||||
生产构建 SHALL 以稳定顺序生成嵌入静态资源清单,减少重复构建产生无意义差异。
|
|
||||||
|
|
||||||
#### Scenario: 生成静态资源清单
|
|
||||||
- **WHEN** 生产构建扫描 Vite 输出目录并生成嵌入资源模块
|
|
||||||
- **THEN** 资源条目 SHALL 按稳定顺序输出
|
|
||||||
|
|
||||||
#### Scenario: 重复构建相同前端产物
|
|
||||||
- **WHEN** Vite 输出内容未变化且生产构建重复运行
|
|
||||||
- **THEN** 生成的嵌入资源模块 SHALL 保持语义一致且不依赖文件系统遍历顺序
|
|
||||||
|
|
||||||
### Requirement: 单 executable 输出
|
### Requirement: 单 executable 输出
|
||||||
生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端、必要 server 依赖和构建后的前端资源。构建成功后 SHALL 自动清理中间产物目录(`.build/`),构建失败时 SHALL 保留中间产物以便排查。生成的入口代码 SHALL 通过 import config-loader 模块隐式触发 checker 注册,而非显式调用注册函数。生成的入口 SHALL 注册 SIGINT 和 SIGTERM 信号处理器,在收到信号时依次调用 engine.stop() 和 store.close() 后退出进程。
|
生产构建 SHALL 输出一个 standalone executable,其中包含 Bun 后端和通过 HTML import manifest 嵌入的前端资源。构建流程 SHALL 不再生成项目自定义中间产物目录,构建失败时 SHALL 不保留 stale executable。
|
||||||
|
|
||||||
#### Scenario: 在目标机器运行 executable
|
#### Scenario: 在目标机器运行 executable
|
||||||
- **WHEN** 生成的 executable 在兼容目标平台上运行
|
- **WHEN** 生成的 executable 在兼容目标平台上运行
|
||||||
- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun、Vite 或 `node_modules`
|
- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun 或 `node_modules`
|
||||||
|
|
||||||
#### Scenario: 服务嵌入的前端
|
#### Scenario: 服务嵌入的前端
|
||||||
- **WHEN** executable 收到前端根路径请求
|
- **WHEN** executable 收到前端根路径请求
|
||||||
- **THEN** 它 SHALL 从 executable 内包含的资源服务前端,且不需要外部 `dist/` 目录
|
- **THEN** 它 SHALL 通过 Bun 内置的 HTML import manifest 机制服务前端资源,且不需要外部 `dist/` 目录
|
||||||
|
|
||||||
#### Scenario: 服务嵌入 API 和页面
|
#### Scenario: 服务嵌入 API 和页面
|
||||||
- **WHEN** 生成的 executable 启动,且浏览器打开前端根路径
|
- **WHEN** 生成的 executable 启动,且浏览器打开前端根路径
|
||||||
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/summary` 和 `/api/targets` 返回的数据
|
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/summary` 和 `/api/targets` 返回的数据
|
||||||
|
|
||||||
#### Scenario: 构建成功后清理中间产物
|
#### Scenario: 构建成功不生成自定义中间产物
|
||||||
- **WHEN** 生产构建成功完成并输出 executable
|
- **WHEN** 生产构建成功完成并输出 executable
|
||||||
- **THEN** 系统 SHALL 自动删除 `.build/` 目录及其所有内容
|
- **THEN** 系统 SHALL 不生成 `.build/` 静态资源清单或 server entry 中间产物
|
||||||
|
|
||||||
#### Scenario: 构建失败时保留中间产物
|
#### Scenario: 构建失败时不保留 stale executable
|
||||||
- **WHEN** 生产构建在任意步骤失败(前端构建、中间产物生成、Bun 编译)
|
- **WHEN** 生产构建在任意步骤失败
|
||||||
- **THEN** `.build/` 目录 SHALL 保留在磁盘上以供排查
|
- **THEN** 系统 SHALL 不输出上一次构建遗留的 stale executable
|
||||||
|
|
||||||
#### Scenario: checker 注册通过 import 链触发
|
|
||||||
- **WHEN** 生成的入口代码 import config-loader 模块
|
|
||||||
- **THEN** checkerRegistry 单例 SHALL 通过模块依赖链自动完成注册,入口代码 SHALL NOT 显式调用任何注册函数
|
|
||||||
|
|
||||||
#### Scenario: 生产入口优雅关闭
|
|
||||||
- **WHEN** executable 进程收到 SIGINT 或 SIGTERM 信号
|
|
||||||
- **THEN** 系统 SHALL 调用 engine.stop() 停止所有定时器,调用 store.close() 关闭数据库连接,然后以退出码 0 退出进程
|
|
||||||
|
|
||||||
### Requirement: 外部运行时配置
|
### Requirement: 外部运行时配置
|
||||||
executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。
|
executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。
|
||||||
@@ -69,20 +50,12 @@ executable MUST 将环境相关运行时配置保留在嵌入的前端和 server
|
|||||||
- **THEN** executable SHALL 使用文档化的默认值
|
- **THEN** executable SHALL 使用文档化的默认值
|
||||||
|
|
||||||
### Requirement: 构建验证
|
### Requirement: 构建验证
|
||||||
项目 SHALL 提供验证,证明生产 executable 可以服务 API、健康检查、静态资源和 SPA fallback 路由,并且完整验证 MUST 针对当前源码重新构建后的 executable 运行。
|
项目 SHALL 提供 `verify` 命令执行质量检查和生产构建;原 smoke test 暂时移除,executable 路由验证由后续变更重新设计。
|
||||||
|
|
||||||
#### Scenario: 验证 executable 路由
|
|
||||||
- **WHEN** 构建验证针对生成的 executable 运行
|
|
||||||
- **THEN** 它 SHALL 检查 `/api/summary`、`/api/targets`、`/health`、前端根路径、静态资源、未知 API、未知静态资源和前端 fallback 请求
|
|
||||||
|
|
||||||
#### Scenario: 验证生产模式和响应头
|
|
||||||
- **WHEN** 构建验证针对生成的 executable 运行
|
|
||||||
- **THEN** 它 SHALL 检查 API 响应处于 production runtime mode,并验证代表性 HTML、JSON 和静态资源响应的缓存或低风险安全 headers
|
|
||||||
|
|
||||||
#### Scenario: 完整验证重新构建 executable
|
#### Scenario: 完整验证重新构建 executable
|
||||||
- **WHEN** 开发者运行完整验证命令
|
- **WHEN** 开发者运行完整验证命令
|
||||||
- **THEN** 系统 MUST 先基于当前源码执行生产构建,再对新生成的 executable 运行 smoke test
|
- **THEN** 系统 MUST 先执行质量检查,再基于当前源码执行生产构建
|
||||||
|
|
||||||
#### Scenario: 验证失败
|
#### Scenario: 验证失败
|
||||||
- **WHEN** 任一代表性生产路由、响应头、生产模式或构建阶段检查失败
|
- **WHEN** 质量检查或构建阶段失败
|
||||||
- **THEN** 验证 SHALL 使构建或测试命令失败
|
- **THEN** 验证 SHALL 使命令失败
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: TanStack Query 数据层
|
### Requirement: TanStack Query 数据层
|
||||||
前端 SHALL 使用 TanStack Query(@tanstack/react-query)管理所有 API 请求,替代手写 fetch hooks。
|
前端 SHALL 使用 TanStack Query(@tanstack/react-query)管理所有 API 请求,数据层代码 SHALL 按职责拆分为独立 hook 文件。
|
||||||
|
|
||||||
#### Scenario: QueryClient 配置
|
#### Scenario: QueryClient 配置
|
||||||
- **WHEN** 应用启动
|
- **WHEN** 应用启动
|
||||||
@@ -34,6 +34,40 @@
|
|||||||
- **WHEN** 查询某目标的历史记录
|
- **WHEN** 查询某目标的历史记录
|
||||||
- **THEN** queryKey SHALL 为 ["history", targetId, from, to, page]
|
- **THEN** queryKey SHALL 为 ["history", targetId, from, to, page]
|
||||||
|
|
||||||
|
#### Scenario: meta queryKey
|
||||||
|
- **WHEN** 查询 meta 数据
|
||||||
|
- **THEN** queryKey SHALL 为 ["meta"]
|
||||||
|
|
||||||
|
### Requirement: Meta 查询
|
||||||
|
系统 SHALL 提供 `useMeta` hook 查询系统元数据。
|
||||||
|
|
||||||
|
#### Scenario: meta 查询配置
|
||||||
|
- **WHEN** 应用启动
|
||||||
|
- **THEN** `useMeta` SHALL 请求 `/api/meta`,配置 `staleTime: Infinity`(应用生命周期内只请求一次)
|
||||||
|
|
||||||
|
#### Scenario: meta 数据返回
|
||||||
|
- **WHEN** meta 查询成功
|
||||||
|
- **THEN** hook SHALL 返回 `MetaResponse` 类型数据,包含 `checkerTypes` 字段
|
||||||
|
|
||||||
|
### Requirement: Hook 文件拆分
|
||||||
|
数据层 hook SHALL 按职责拆分为独立文件。
|
||||||
|
|
||||||
|
#### Scenario: 全局查询 hook 文件
|
||||||
|
- **WHEN** 开发者需要使用全局面板级查询
|
||||||
|
- **THEN** `useSummary`、`useTargets`、`useMeta` SHALL 从 `hooks/use-queries.ts` 导出
|
||||||
|
|
||||||
|
#### Scenario: Drawer 状态 hook 文件
|
||||||
|
- **WHEN** 开发者需要使用 Drawer 状态管理
|
||||||
|
- **THEN** `useTargetDetail` SHALL 从 `hooks/use-target-detail.ts` 导出
|
||||||
|
|
||||||
|
#### Scenario: fetchJson 不导出
|
||||||
|
- **WHEN** 数据层内部需要 fetch 封装
|
||||||
|
- **THEN** `fetchJson` SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
|
||||||
|
|
||||||
|
#### Scenario: queryKeys 不导出
|
||||||
|
- **WHEN** 数据层内部需要 query key
|
||||||
|
- **THEN** `queryKeys` 对象 SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
|
||||||
|
|
||||||
### Requirement: Summary 轮询查询
|
### Requirement: Summary 轮询查询
|
||||||
系统 SHALL 使用 useQuery 实现总览统计的自动轮询。
|
系统 SHALL 使用 useQuery 实现总览统计的自动轮询。
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: 目标详情 Drawer
|
### Requirement: 目标详情 Drawer
|
||||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。
|
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。
|
||||||
|
|
||||||
#### Scenario: 打开 Drawer
|
#### Scenario: 打开 Drawer
|
||||||
- **WHEN** 用户点击某个目标表格行
|
- **WHEN** 用户点击某个目标表格行
|
||||||
@@ -13,7 +13,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示
|
|||||||
|
|
||||||
#### Scenario: Drawer 标题栏
|
#### Scenario: Drawer 标题栏
|
||||||
- **WHEN** Drawer 渲染
|
- **WHEN** Drawer 渲染
|
||||||
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件(align="center")布局,包含 StatusDot、目标名称(TDesign Typography.Text strong)和类型标签(TDesign Tag),以及内建关闭按钮。不使用内联 style 的 flex 布局
|
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件(align="center")布局,包含 StatusDot、目标名称(TDesign Typography.Text strong)和类型标签(TDesign Tag,直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局
|
||||||
|
|
||||||
#### Scenario: 关闭 Drawer
|
#### Scenario: 关闭 Drawer
|
||||||
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
|
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
|
||||||
@@ -35,6 +35,65 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示
|
|||||||
- **WHEN** Drawer 内容渲染
|
- **WHEN** Drawer 内容渲染
|
||||||
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件(direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
|
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件(direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
|
||||||
|
|
||||||
|
### Requirement: 概览面板组件化
|
||||||
|
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,接收数据 props 进行渲染。
|
||||||
|
|
||||||
|
#### Scenario: OverviewTab 组件职责
|
||||||
|
- **WHEN** 概览 Tab 渲染
|
||||||
|
- **THEN** `OverviewTab` 组件 SHALL 负责统计卡片、趋势图、状态分布环形图和基本信息的渲染
|
||||||
|
|
||||||
|
#### Scenario: 统计计算使用纯函数
|
||||||
|
- **WHEN** OverviewTab 需要计算 totalChecks、upChecks、downChecks
|
||||||
|
- **THEN** 计算逻辑 SHALL 通过 `utils/stats.ts` 中的纯函数实现,并使用 `useMemo` 缓存结果
|
||||||
|
|
||||||
|
#### Scenario: OverviewTab props
|
||||||
|
- **WHEN** OverviewTab 渲染
|
||||||
|
- **THEN** 组件 SHALL 接收 `target: TargetStatus`、`trendData: TrendPoint[]`、`trendLoading: boolean` 作为 props
|
||||||
|
|
||||||
|
### Requirement: 记录面板组件化
|
||||||
|
记录 Tab SHALL 作为独立组件 `HistoryTab` 实现。
|
||||||
|
|
||||||
|
#### Scenario: HistoryTab 组件职责
|
||||||
|
- **WHEN** 记录 Tab 渲染
|
||||||
|
- **THEN** `HistoryTab` 组件 SHALL 负责检查结果表格和分页的渲染
|
||||||
|
|
||||||
|
#### Scenario: HistoryTab props
|
||||||
|
- **WHEN** HistoryTab 渲染
|
||||||
|
- **THEN** 组件 SHALL 接收 `historyData: HistoryResponse`、`historyLoading: boolean`、`onPageChange: (page: number) => void` 作为 props
|
||||||
|
|
||||||
|
#### Scenario: 历史记录列定义外置
|
||||||
|
- **WHEN** HistoryTab 渲染表格
|
||||||
|
- **THEN** 列定义 SHALL 从 `constants/history-table-columns.tsx` 导入,不在组件内部定义
|
||||||
|
|
||||||
|
### Requirement: TrendChart 简化
|
||||||
|
TrendChart 组件 SHALL 仅接收数据 props,不处理 loading 状态。
|
||||||
|
|
||||||
|
#### Scenario: TrendChart 无 loading prop
|
||||||
|
- **WHEN** TrendChart 渲染
|
||||||
|
- **THEN** 组件 SHALL 仅接收 `data: TrendPoint[]` prop,不接收 `loading` prop
|
||||||
|
|
||||||
|
#### Scenario: TrendChart 空数据
|
||||||
|
- **WHEN** TrendChart 接收空数组
|
||||||
|
- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本
|
||||||
|
|
||||||
|
### Requirement: StatusDonut key 修复
|
||||||
|
StatusDonut 组件 SHALL 使用语义化的 key。
|
||||||
|
|
||||||
|
#### Scenario: Pie Cell key
|
||||||
|
- **WHEN** StatusDonut 渲染 Pie Cell 列表
|
||||||
|
- **THEN** 每个 Cell 的 key SHALL 使用 data item 的 `name` 字段,不使用数组索引
|
||||||
|
|
||||||
|
### Requirement: StatusBar 参数化
|
||||||
|
StatusBar 组件 SHALL 支持可配置的格数。
|
||||||
|
|
||||||
|
#### Scenario: maxSlots prop
|
||||||
|
- **WHEN** StatusBar 渲染
|
||||||
|
- **THEN** 组件 SHALL 接收可选的 `maxSlots` prop(默认 30),根据该值渲染对应数量的格子
|
||||||
|
|
||||||
|
#### Scenario: 格子渲染逻辑
|
||||||
|
- **WHEN** StatusBar 渲染且 samples 数量少于 maxSlots
|
||||||
|
- **THEN** 多余的格子 SHALL 显示为 empty 状态
|
||||||
|
|
||||||
### Requirement: 时间范围选择器
|
### Requirement: 时间范围选择器
|
||||||
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。时间选择器 SHALL 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。
|
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。时间选择器 SHALL 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
|
|||||||
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件(direction=vertical, size=32px)统一间距
|
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件(direction=vertical, size=32px)统一间距
|
||||||
|
|
||||||
### Requirement: 表格列定义
|
### Requirement: 表格列定义
|
||||||
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。
|
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
|
||||||
|
|
||||||
#### Scenario: 状态列
|
#### Scenario: 状态列
|
||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
@@ -44,7 +44,11 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
|
|||||||
|
|
||||||
#### Scenario: 类型列
|
#### Scenario: 类型列
|
||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)显示类型名称,支持单选筛选
|
- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)直接显示 target.type 原始文本,支持单选筛选
|
||||||
|
|
||||||
|
#### Scenario: 类型筛选器动态生成
|
||||||
|
- **WHEN** 表格渲染
|
||||||
|
- **THEN** 类型列的筛选器列表 SHALL 从 meta API 返回的 `checkerTypes` 动态生成,包含"全部"选项和每个 checker 类型选项(label 和 value 均为 type 原始文本)
|
||||||
|
|
||||||
#### Scenario: 可用率列
|
#### Scenario: 可用率列
|
||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
@@ -52,7 +56,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
|
|||||||
|
|
||||||
#### Scenario: 最近状态列
|
#### Scenario: 最近状态列
|
||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染 30 格采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style
|
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style
|
||||||
|
|
||||||
#### Scenario: 延迟列
|
#### Scenario: 延迟列
|
||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
@@ -62,6 +66,32 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
|
|||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
|
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
|
||||||
|
|
||||||
|
### Requirement: 列定义工厂函数
|
||||||
|
列定义 SHALL 通过工厂函数生成,接收动态参数。
|
||||||
|
|
||||||
|
#### Scenario: createTargetTableColumns 函数
|
||||||
|
- **WHEN** 需要生成表格列定义
|
||||||
|
- **THEN** 系统 SHALL 调用 `createTargetTableColumns(checkerTypes: string[])` 函数,返回 `PrimaryTableCol<TargetStatus>[]`
|
||||||
|
|
||||||
|
#### Scenario: checkerTypes 为空数组
|
||||||
|
- **WHEN** meta API 尚未返回或返回空数组
|
||||||
|
- **THEN** 类型列的筛选器 SHALL 仅包含"全部"选项
|
||||||
|
|
||||||
|
#### Scenario: 列定义缓存
|
||||||
|
- **WHEN** TargetBoard 组件渲染
|
||||||
|
- **THEN** 列定义 SHALL 通过 `useMemo` 缓存,仅在 `checkerTypes` 变化时重新生成
|
||||||
|
|
||||||
|
### Requirement: TargetGroup 接收 columns prop
|
||||||
|
TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态常量。
|
||||||
|
|
||||||
|
#### Scenario: columns prop
|
||||||
|
- **WHEN** TargetGroup 渲染
|
||||||
|
- **THEN** 组件 SHALL 接收 `columns: PrimaryTableCol<TargetStatus>[]` prop 并传递给 PrimaryTable
|
||||||
|
|
||||||
|
#### Scenario: TargetBoard 传递 columns
|
||||||
|
- **WHEN** TargetBoard 渲染子组件
|
||||||
|
- **THEN** TargetBoard SHALL 调用 `createTargetTableColumns` 生成列定义并传递给每个 TargetGroup
|
||||||
|
|
||||||
### Requirement: 默认排序
|
### Requirement: 默认排序
|
||||||
表格 SHALL 默认按状态降序排列,异常(DOWN)目标排在最前面。
|
表格 SHALL 默认按状态降序排列,异常(DOWN)目标排在最前面。
|
||||||
|
|
||||||
@@ -103,7 +133,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
|
|||||||
- **THEN** 表格 SHALL 设置 size="small"、stripe、hover、bordered
|
- **THEN** 表格 SHALL 设置 size="small"、stripe、hover、bordered
|
||||||
|
|
||||||
### Requirement: 列定义复用
|
### Requirement: 列定义复用
|
||||||
所有分组的表格 SHALL 共享同一套列定义常量。
|
所有分组的表格 SHALL 共享同一套列定义。
|
||||||
|
|
||||||
#### Scenario: 列定义提取为常量
|
#### Scenario: 列定义提取为常量
|
||||||
- **WHEN** 多个分组表格渲染
|
- **WHEN** 多个分组表格渲染
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
## Purpose
|
|
||||||
|
|
||||||
定义目标类型(Target Type)的前端显示名称映射系统,支持从后端类型标识符到 TDesign Tag 组件展示的可扩展转换。
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Requirement: 类型显示名称映射
|
|
||||||
系统 SHALL 提供目标类型到显示名称的映射,将后端类型标识符转换为 TDesign Tag 组件的展示文本。
|
|
||||||
|
|
||||||
#### Scenario: HTTP 类型显示
|
|
||||||
- **WHEN** 目标类型为 "http"
|
|
||||||
- **THEN** 前端 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)显示 "HTTP"
|
|
||||||
|
|
||||||
#### Scenario: Command 类型显示
|
|
||||||
- **WHEN** 目标类型为 "command"
|
|
||||||
- **THEN** 前端 SHALL 使用 TDesign Tag 组件显示 "CMD"
|
|
||||||
|
|
||||||
#### Scenario: 未知类型处理
|
|
||||||
- **WHEN** 目标类型不在映射表中
|
|
||||||
- **THEN** 前端 SHALL 将类型名称转换为大写显示在 TDesign Tag 组件中
|
|
||||||
|
|
||||||
### Requirement: 映射可扩展性
|
|
||||||
类型映射系统 SHALL 支持后续新增类型,无需修改多处代码。
|
|
||||||
|
|
||||||
#### Scenario: 新增类型映射
|
|
||||||
- **WHEN** 需要新增目标类型(如 "tcp"、"dns"、"grpc")
|
|
||||||
- **THEN** 开发者 SHALL 仅需在映射常量中添加一条记录
|
|
||||||
|
|
||||||
#### Scenario: 映射单一数据源
|
|
||||||
- **WHEN** 前端组件需要显示目标类型
|
|
||||||
- **THEN** 组件 SHALL 调用统一的映射函数,不直接硬编码映射逻辑
|
|
||||||
|
|
||||||
### Requirement: 类型安全
|
|
||||||
类型映射系统 SHALL 提供类型安全的访问方式。
|
|
||||||
|
|
||||||
#### Scenario: TypeScript 类型推导
|
|
||||||
- **WHEN** 使用映射常量
|
|
||||||
- **THEN** TypeScript SHALL 能够推导出正确的类型(使用 `as const`)
|
|
||||||
|
|
||||||
#### Scenario: 运行时安全
|
|
||||||
- **WHEN** 传入无效类型
|
|
||||||
- **THEN** 系统 SHALL 返回 fallback 值,不抛出异常
|
|
||||||
11
package.json
11
package.json
@@ -3,18 +3,15 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run scripts/dev.ts",
|
"dev": "bun --watch src/server/dev.ts",
|
||||||
"dev:server": "bun --watch src/server/dev.ts",
|
|
||||||
"dev:web": "bunx --bun vite --host 127.0.0.1",
|
|
||||||
"build": "bun run scripts/build.ts",
|
"build": "bun run scripts/build.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier . --write",
|
"format": "prettier . --write",
|
||||||
"schema": "bun run scripts/generate-config-schema.ts",
|
"schema": "bun run scripts/generate-config-schema.ts",
|
||||||
"schema:check": "bun run scripts/generate-config-schema.ts --check",
|
"schema:check": "bun run scripts/generate-config-schema.ts --check",
|
||||||
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
|
"check": "bun run schema:check && bun run typecheck && bun run lint && bun test",
|
||||||
"verify": "bun run check && bun run build && bun run test:smoke",
|
"verify": "bun run check && bun run build",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:smoke": "bun run scripts/smoke.ts",
|
|
||||||
"clean": "bun run scripts/clean.ts",
|
"clean": "bun run scripts/clean.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
@@ -27,7 +24,6 @@
|
|||||||
"@types/bun": "^1.3.13",
|
"@types/bun": "^1.3.13",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
@@ -40,8 +36,7 @@
|
|||||||
"lint-staged": "^17.0.4",
|
"lint-staged": "^17.0.4",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2"
|
||||||
"vite": "^8.0.11"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "^0.34.49",
|
"@sinclair/typebox": "^0.34.49",
|
||||||
|
|||||||
102
scripts/build.ts
102
scripts/build.ts
@@ -1,30 +1,10 @@
|
|||||||
import { $ } from "bun";
|
import { rm } from "node:fs/promises";
|
||||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { dirname, relative, sep } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const buildDir = fileURLToPath(new URL("../.build/", import.meta.url));
|
|
||||||
const webDistDir = fileURLToPath(new URL("../dist/web/", import.meta.url));
|
|
||||||
const executablePath = fileURLToPath(new URL("../dist/dial-server", import.meta.url));
|
const executablePath = fileURLToPath(new URL("../dist/dial-server", import.meta.url));
|
||||||
const generatedAssetsPath = fileURLToPath(new URL("../.build/static-assets.ts", import.meta.url));
|
const entrypoint = fileURLToPath(new URL("../src/server/main.ts", import.meta.url));
|
||||||
const generatedEntryPath = fileURLToPath(new URL("../.build/server-entry.ts", import.meta.url));
|
|
||||||
|
|
||||||
await rm(buildDir, { force: true, recursive: true });
|
|
||||||
await rm(executablePath, { force: true });
|
await rm(executablePath, { force: true });
|
||||||
await mkdir(buildDir, { recursive: true });
|
|
||||||
|
|
||||||
await $`bunx --bun vite build`;
|
|
||||||
|
|
||||||
const files = await listFiles(webDistDir);
|
|
||||||
const indexPath = files.find((file) => normalize(relative(webDistDir, file)) === "index.html");
|
|
||||||
|
|
||||||
if (!indexPath) {
|
|
||||||
throw new Error("Vite build 未生成 dist/web/index.html");
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetFiles = files.filter((file) => file !== indexPath);
|
|
||||||
await writeGeneratedAssets(indexPath, assetFiles);
|
|
||||||
await writeGeneratedEntry();
|
|
||||||
|
|
||||||
const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"];
|
const target = process.env["BUN_TARGET"] ?? process.env["BUILD_TARGET"];
|
||||||
const result = await Bun.build({
|
const result = await Bun.build({
|
||||||
@@ -40,86 +20,14 @@ const result = await Bun.build({
|
|||||||
autoloadDotenv: true,
|
autoloadDotenv: true,
|
||||||
outfile: executablePath,
|
outfile: executablePath,
|
||||||
},
|
},
|
||||||
entrypoints: [generatedEntryPath],
|
entrypoints: [entrypoint],
|
||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: "linked",
|
sourcemap: "linked",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
await rm(executablePath, { force: true });
|
console.error("构建失败:", result.logs);
|
||||||
throw new Error("Bun executable 构建失败");
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Built executable: ${executablePath}`);
|
console.log(`Built executable: ${executablePath}`);
|
||||||
|
|
||||||
await rm(buildDir, { force: true, recursive: true });
|
|
||||||
|
|
||||||
async function listFiles(directory: string): Promise<string[]> {
|
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
|
||||||
const files = await Promise.all(
|
|
||||||
entries.map(async (entry) => {
|
|
||||||
const path = `${directory.replace(/\/$/, "")}/${entry.name}`;
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
return listFiles(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [path];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return files.flat().sort((left, right) => normalize(left).localeCompare(normalize(right)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(path: string): string {
|
|
||||||
return path.split(sep).join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toImportPath(path: string): string {
|
|
||||||
const rel = normalize(relative(buildDir, path));
|
|
||||||
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeGeneratedAssets(indexPath: string, assetFiles: string[]) {
|
|
||||||
const imports = [
|
|
||||||
`import type { StaticAssets } from "../src/server/app";`,
|
|
||||||
`import indexPath from "${toImportPath(indexPath)}" with { type: "file" };`,
|
|
||||||
...assetFiles.map((file, index) => `import asset${index}Path from "${toImportPath(file)}" with { type: "file" };`),
|
|
||||||
];
|
|
||||||
const assetEntries = assetFiles.map((file, index) => {
|
|
||||||
const urlPath = `/${normalize(relative(webDistDir, file))}`;
|
|
||||||
return ` ${JSON.stringify(urlPath)}: Bun.file(asset${index}Path),`;
|
|
||||||
});
|
|
||||||
const source = `${imports.join("\n")}
|
|
||||||
|
|
||||||
export const staticAssets: StaticAssets = {
|
|
||||||
indexHtml: Bun.file(indexPath),
|
|
||||||
files: {
|
|
||||||
${assetEntries.join("\n")}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
|
|
||||||
await mkdir(dirname(generatedAssetsPath), { recursive: true });
|
|
||||||
await writeFile(generatedAssetsPath, source);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeGeneratedEntry() {
|
|
||||||
await writeFile(
|
|
||||||
generatedEntryPath,
|
|
||||||
`import { bootstrap } from "../src/server/bootstrap";
|
|
||||||
import { readRuntimeConfig } from "../src/server/config";
|
|
||||||
import { staticAssets } from "./static-assets";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const { configPath } = readRuntimeConfig();
|
|
||||||
await bootstrap({ configPath, mode: "production", staticAssets });
|
|
||||||
}
|
|
||||||
|
|
||||||
void main().catch((error) => {
|
|
||||||
console.error("启动失败:", error instanceof Error ? error.message : error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
interface ChildProcessInfo {
|
|
||||||
name: string;
|
|
||||||
process: Bun.Subprocess;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = process.argv[2];
|
|
||||||
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
BACKEND_PORT: process.env["PORT"] ?? "3000",
|
|
||||||
};
|
|
||||||
|
|
||||||
const children: ChildProcessInfo[] = [
|
|
||||||
{
|
|
||||||
name: "server",
|
|
||||||
process: Bun.spawn(["bun", "run", "dev:server", ...(configPath ? [configPath] : [])], {
|
|
||||||
env,
|
|
||||||
stderr: "inherit",
|
|
||||||
stdout: "inherit",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "web",
|
|
||||||
process: Bun.spawn(["bun", "run", "dev:web"], {
|
|
||||||
env,
|
|
||||||
stderr: "inherit",
|
|
||||||
stdout: "inherit",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const stopChildren = () => {
|
|
||||||
for (const child of children) {
|
|
||||||
child.process.kill();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
stopChildren();
|
|
||||||
process.exit(130);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
|
||||||
stopChildren();
|
|
||||||
process.exit(143);
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstExit = await Promise.race(
|
|
||||||
children.map(async (child) => ({ code: await child.process.exited, name: child.name })),
|
|
||||||
);
|
|
||||||
|
|
||||||
stopChildren();
|
|
||||||
|
|
||||||
if (firstExit.code !== 0) {
|
|
||||||
console.error(`${firstExit.name} exited with code ${firstExit.code}`);
|
|
||||||
process.exit(firstExit.code ?? 1);
|
|
||||||
}
|
|
||||||
168
scripts/smoke.ts
168
scripts/smoke.ts
@@ -1,168 +0,0 @@
|
|||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
||||||
import { access } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
import type { HealthResponse, SummaryResponse } from "../src/shared/api";
|
|
||||||
|
|
||||||
const executablePath = process.argv[2] ?? fileURLToPath(new URL("../dist/dial-server", import.meta.url));
|
|
||||||
|
|
||||||
await assertExecutableExists(executablePath);
|
|
||||||
|
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), "dial-smoke-"));
|
|
||||||
const configPath = join(tempDir, "probes.yaml");
|
|
||||||
|
|
||||||
const port = getFreePort();
|
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
configPath,
|
|
||||||
`server:
|
|
||||||
port: ${port}
|
|
||||||
targets:
|
|
||||||
- name: "httpbin"
|
|
||||||
type: http
|
|
||||||
http:
|
|
||||||
url: "https://httpbin.org/get"
|
|
||||||
interval: "5m"
|
|
||||||
timeout: "15s"
|
|
||||||
expect:
|
|
||||||
status: [200]
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
const app = Bun.spawn([executablePath, configPath], {
|
|
||||||
env: { ...process.env },
|
|
||||||
stderr: "pipe",
|
|
||||||
stdout: "pipe",
|
|
||||||
});
|
|
||||||
const stdout = readStream(app.stdout);
|
|
||||||
const stderr = readStream(app.stderr);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForServer(`${baseUrl}/health`);
|
|
||||||
|
|
||||||
const { body: health, response: healthResponse } = await expectJson<HealthResponse>(`${baseUrl}/health`, 200);
|
|
||||||
assert(health.ok === true, "健康检查响应缺少 ok=true");
|
|
||||||
assertSecurityHeaders(healthResponse, "/health");
|
|
||||||
|
|
||||||
const { body: summary } = await expectJson<SummaryResponse>(`${baseUrl}/api/summary`, 200);
|
|
||||||
assert(summary.total === 1, "总览统计: total 应为 1");
|
|
||||||
assertSecurityHeaders(await fetch(`${baseUrl}/api/summary`), "/api/summary");
|
|
||||||
|
|
||||||
const { body: targets } = await expectJson<unknown[]>(`${baseUrl}/api/targets`, 200);
|
|
||||||
assert(Array.isArray(targets), "/api/targets 应返回数组");
|
|
||||||
assert(targets.length === 1, "/api/targets 应有 1 个目标");
|
|
||||||
assert((targets[0] as { name: string }).name === "httpbin", "目标名称应为 httpbin");
|
|
||||||
|
|
||||||
const missingApi = await fetch(`${baseUrl}/api/not-found`);
|
|
||||||
assert(missingApi.status === 404, "未知 API 应返回 404");
|
|
||||||
|
|
||||||
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("DiAL"), "前端根页面缺少标题");
|
|
||||||
assert(rootResponse.headers.get("cache-control") === "no-cache", "前端根页面应使用 no-cache");
|
|
||||||
|
|
||||||
const { body: fallbackHtml } = await expectText(`${baseUrl}/dashboard`, 200);
|
|
||||||
assert(fallbackHtml.includes("DiAL"), "SPA fallback 未返回前端入口页面");
|
|
||||||
|
|
||||||
const assetPath = /(?:src|href)="(\/assets\/[^"]+)"/.exec(rootHtml)?.[1];
|
|
||||||
assert(assetPath !== undefined, "前端入口页面未引用 /assets/* 资源");
|
|
||||||
|
|
||||||
const asset = await fetch(`${baseUrl}${assetPath}`);
|
|
||||||
assert(asset.status === 200, `静态资源 ${assetPath} 未返回 200`);
|
|
||||||
|
|
||||||
const missingAsset = await expectText(`${baseUrl}/assets/not-found.js`, 404);
|
|
||||||
assert(!missingAsset.body.includes("DiAL"), "未知静态资源不应返回前端入口页面");
|
|
||||||
|
|
||||||
console.log(`Smoke test passed: ${baseUrl}`);
|
|
||||||
} catch (error) {
|
|
||||||
app.kill();
|
|
||||||
const [out, err] = await Promise.all([stdout, stderr]);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
|
|
||||||
throw new Error(`executable smoke test 失败: ${message}\nstdout:\n${out}\nstderr:\n${err}`, { cause: error });
|
|
||||||
} finally {
|
|
||||||
app.kill();
|
|
||||||
rmSync(tempDir, { force: true, recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function assert(condition: boolean, message: string): asserts condition {
|
|
||||||
if (!condition) {
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertExecutableExists(path: string) {
|
|
||||||
try {
|
|
||||||
await access(path);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`找不到 executable: ${path},请先运行 bun run build`, { cause: error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSecurityHeaders(response: Response, label: string) {
|
|
||||||
assert(response.headers.get("x-content-type-options") === "nosniff", `${label} 缺少 nosniff 安全头`);
|
|
||||||
assert(
|
|
||||||
response.headers.get("referrer-policy") === "strict-origin-when-cross-origin",
|
|
||||||
`${label} 缺少 Referrer-Policy 安全头`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectJson<T = unknown>(url: string, status: number): Promise<{ body: T; response: Response }> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
|
|
||||||
assert(response.headers.get("content-type")?.includes("application/json") === true, `${url} 应返回 JSON`);
|
|
||||||
|
|
||||||
return { body: (await response.json()) as T, response };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectText(url: string, status: number): Promise<{ body: string; response: Response }> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
assert(response.status === status, `${url} 应返回 ${status},实际为 ${response.status}`);
|
|
||||||
|
|
||||||
return { body: await response.text(), response };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFreePort(): number {
|
|
||||||
const server = Bun.serve({
|
|
||||||
fetch: () => new Response("ok"),
|
|
||||||
hostname: "127.0.0.1",
|
|
||||||
port: 0,
|
|
||||||
});
|
|
||||||
const port = server.port;
|
|
||||||
|
|
||||||
void server.stop(true);
|
|
||||||
|
|
||||||
if (port === undefined) {
|
|
||||||
throw new Error("无法分配 smoke test 端口");
|
|
||||||
}
|
|
||||||
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readStream(stream: null | ReadableStream<Uint8Array>): Promise<string> {
|
|
||||||
if (!stream) return "";
|
|
||||||
|
|
||||||
return new Response(stream).text();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForServer(url: string) {
|
|
||||||
const deadline = Date.now() + 8_000;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (response.ok) return;
|
|
||||||
} catch {
|
|
||||||
await Bun.sleep(100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`服务未在超时时间内启动: ${url}`);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import type { RuntimeMode } from "../shared/api";
|
|
||||||
import type { ProbeStore } from "./checker/store";
|
|
||||||
|
|
||||||
import { createApiError, jsonResponse } from "./helpers";
|
|
||||||
import { guardGetHead } from "./middleware";
|
|
||||||
import { handleHealth } from "./routes/health";
|
|
||||||
import { handleHistory } from "./routes/history";
|
|
||||||
import { handleSummary } from "./routes/summary";
|
|
||||||
import { handleTargets } from "./routes/targets";
|
|
||||||
import { handleTrend } from "./routes/trend";
|
|
||||||
import { serveStaticAsset } from "./static";
|
|
||||||
|
|
||||||
export interface AppOptions {
|
|
||||||
mode: RuntimeMode;
|
|
||||||
staticAssets?: StaticAssets;
|
|
||||||
store?: ProbeStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StaticAssets {
|
|
||||||
files: Record<string, Blob>;
|
|
||||||
indexHtml: Blob;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFetchHandler(options: AppOptions) {
|
|
||||||
return (request: Request): Response => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
if (url.pathname === "/health") {
|
|
||||||
return handleHealth(request.method, 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("Service not ready", 503), {
|
|
||||||
method: request.method,
|
|
||||||
mode: options.mode,
|
|
||||||
status: 503,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.staticAssets) {
|
|
||||||
return serveStaticAsset(url.pathname, options.staticAssets, options.mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("开发期请通过 Vite 前端地址访问页面。", {
|
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
||||||
status: 404,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: RuntimeMode): Response {
|
|
||||||
const guardResult = guardGetHead(request.method, mode);
|
|
||||||
if (guardResult) return guardResult;
|
|
||||||
|
|
||||||
const method = request.method;
|
|
||||||
|
|
||||||
if (url.pathname === "/api/summary") {
|
|
||||||
return handleSummary(store, method, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === "/api/targets") {
|
|
||||||
return handleTargets(store, method, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyMatch = /^\/api\/targets\/([^/]+)\/history$/.exec(url.pathname);
|
|
||||||
if (historyMatch) {
|
|
||||||
return handleHistory(historyMatch[1]!, url, method, store, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const trendMatch = /^\/api\/targets\/([^/]+)\/trend$/.exec(url.pathname);
|
|
||||||
if (trendMatch) {
|
|
||||||
return handleTrend(trendMatch[1]!, url, method, store, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 });
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import type { RuntimeMode } from "../shared/api";
|
import type { RuntimeMode } from "../shared/api";
|
||||||
import type { StaticAssets } from "./app";
|
|
||||||
import type { StartServerOptions } from "./server";
|
import type { StartServerOptions } from "./server";
|
||||||
|
|
||||||
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
|
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
|
||||||
@@ -27,7 +26,6 @@ export interface BootstrapDependencies {
|
|||||||
export interface BootstrapOptions {
|
export interface BootstrapOptions {
|
||||||
configPath: string;
|
configPath: string;
|
||||||
mode: RuntimeMode;
|
mode: RuntimeMode;
|
||||||
staticAssets?: StaticAssets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
|
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
|
||||||
@@ -71,7 +69,6 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
|
|||||||
serve({
|
serve({
|
||||||
config: { host: config.host, port: config.port },
|
config: { host: config.host, port: config.port },
|
||||||
mode: options.mode,
|
mode: options.mode,
|
||||||
staticAssets: options.staticAssets,
|
|
||||||
store,
|
store,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export function mismatchFailure(
|
|||||||
|
|
||||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||||
if (value === undefined || value === null) return value;
|
if (value === undefined || value === null) return value;
|
||||||
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
||||||
|
const str = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : undefined;
|
||||||
|
if (str === undefined) return value;
|
||||||
if (str.length <= maxLen) return value;
|
if (str.length <= maxLen) return value;
|
||||||
return str.slice(0, maxLen) + "...";
|
return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/server/checker/expect/redos.ts
Normal file
151
src/server/checker/expect/redos.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
export function isUnsafeRegex(pattern: string): boolean {
|
||||||
|
const groups = findQuantifiedGroups(pattern);
|
||||||
|
return groups.some((group) => containsQuantifier(group) || containsOverlappingAlternation(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsOverlappingAlternation(pattern: string): boolean {
|
||||||
|
const branches = splitTopLevelAlternation(stripGroupPrefix(pattern));
|
||||||
|
if (branches.length < 2) return false;
|
||||||
|
|
||||||
|
for (let i = 0; i < branches.length; i++) {
|
||||||
|
const current = branches[i]!;
|
||||||
|
if (current === "") continue;
|
||||||
|
for (let j = i + 1; j < branches.length; j++) {
|
||||||
|
const next = branches[j]!;
|
||||||
|
if (next === "") continue;
|
||||||
|
if (current === next || current.startsWith(next) || next.startsWith(current)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsQuantifier(pattern: string): boolean {
|
||||||
|
const input = stripGroupPrefix(pattern);
|
||||||
|
let inCharClass = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const char = input[i]!;
|
||||||
|
if (isEscaped(input, i)) continue;
|
||||||
|
if (char === "[") {
|
||||||
|
inCharClass = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "]") {
|
||||||
|
inCharClass = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCharClass) continue;
|
||||||
|
if (char === "*" || char === "+" || char === "?") return true;
|
||||||
|
if (char === "{" && readQuantifierBody(input, i) !== null) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findQuantifiedGroups(pattern: string): string[] {
|
||||||
|
const groups: string[] = [];
|
||||||
|
const stack: number[] = [];
|
||||||
|
let inCharClass = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const char = pattern[i]!;
|
||||||
|
if (isEscaped(pattern, i)) continue;
|
||||||
|
if (char === "[") {
|
||||||
|
inCharClass = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "]") {
|
||||||
|
inCharClass = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCharClass) continue;
|
||||||
|
|
||||||
|
if (char === "(") {
|
||||||
|
stack.push(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ")") {
|
||||||
|
const start = stack.pop();
|
||||||
|
if (start === undefined) continue;
|
||||||
|
if (hasRepeatingQuantifierAt(pattern, i + 1)) {
|
||||||
|
groups.push(pattern.slice(start + 1, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRepeatingQuantifierAt(pattern: string, index: number): boolean {
|
||||||
|
const char = pattern[index];
|
||||||
|
if (char === "*" || char === "+") return true;
|
||||||
|
if (char !== "{") return false;
|
||||||
|
|
||||||
|
const body = readQuantifierBody(pattern, index);
|
||||||
|
if (body === null) return false;
|
||||||
|
const parts = body.split(",");
|
||||||
|
if (parts.length === 1) return Number(parts[0]) > 1;
|
||||||
|
if (parts[1] === "") return true;
|
||||||
|
return Number(parts[1]) > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEscaped(pattern: string, index: number): boolean {
|
||||||
|
let slashCount = 0;
|
||||||
|
for (let i = index - 1; i >= 0 && pattern[i] === "\\"; i--) {
|
||||||
|
slashCount++;
|
||||||
|
}
|
||||||
|
return slashCount % 2 === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readQuantifierBody(pattern: string, index: number): null | string {
|
||||||
|
const end = pattern.indexOf("}", index + 1);
|
||||||
|
if (end === -1) return null;
|
||||||
|
|
||||||
|
const body = pattern.slice(index + 1, end);
|
||||||
|
return /^\d+(?:,\d*)?$/.test(body) ? body : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTopLevelAlternation(pattern: string): string[] {
|
||||||
|
const branches: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let depth = 0;
|
||||||
|
let inCharClass = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const char = pattern[i]!;
|
||||||
|
if (isEscaped(pattern, i)) continue;
|
||||||
|
if (char === "[") {
|
||||||
|
inCharClass = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "]") {
|
||||||
|
inCharClass = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCharClass) continue;
|
||||||
|
if (char === "(") {
|
||||||
|
depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ")") {
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "|" && depth === 0) {
|
||||||
|
branches.push(pattern.slice(start, i));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
branches.push(pattern.slice(start));
|
||||||
|
return branches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripGroupPrefix(pattern: string): string {
|
||||||
|
if (pattern.startsWith("?:") || pattern.startsWith("?=") || pattern.startsWith("?!")) return pattern.slice(2);
|
||||||
|
if (pattern.startsWith("?<=") || pattern.startsWith("?<!")) return pattern.slice(3);
|
||||||
|
|
||||||
|
const namedCapture = /^\?<[^>]+>/.exec(pattern);
|
||||||
|
return namedCapture ? pattern.slice(namedCapture[0].length) : pattern;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { JsonValue } from "../types";
|
|||||||
|
|
||||||
import { OperatorKeys } from "../schema/fragments";
|
import { OperatorKeys } from "../schema/fragments";
|
||||||
import { issue, joinPath } from "../schema/issues";
|
import { issue, joinPath } from "../schema/issues";
|
||||||
|
import { isUnsafeRegex } from "./redos";
|
||||||
|
|
||||||
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
|
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
|
||||||
|
|
||||||
@@ -70,10 +71,10 @@ export function validateOperatorValue(
|
|||||||
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||||
try {
|
try {
|
||||||
new RegExp(value);
|
new RegExp(value);
|
||||||
return [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
||||||
}
|
}
|
||||||
|
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
|
||||||
default:
|
default:
|
||||||
return [issue("unknown-operator", path, "是未知 operator", targetName)];
|
return [issue("unknown-operator", path, "是未知 operator", targetName)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
|
|||||||
import { errorFailure, mismatchFailure } from "../../expect/failure";
|
import { errorFailure, mismatchFailure } from "../../expect/failure";
|
||||||
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
|
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
|
||||||
|
|
||||||
|
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
|
||||||
|
|
||||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
||||||
if (!rules || rules.length === 0) return { failure: null, matched: true };
|
if (!rules || rules.length === 0) return { failure: null, matched: true };
|
||||||
|
|
||||||
|
let parsedJson: ParsedJsonResult | undefined;
|
||||||
|
|
||||||
for (let i = 0; i < rules.length; i++) {
|
for (let i = 0; i < rules.length; i++) {
|
||||||
const result = checkSingleBodyRule(body, rules[i]!, i);
|
const rule = rules[i]!;
|
||||||
|
if ("json" in rule && parsedJson === undefined) {
|
||||||
|
parsedJson = parseJsonBody(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = checkSingleBodyRule(body, rule, i, parsedJson);
|
||||||
if (!result.matched) return result;
|
if (!result.matched) return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,36 +43,7 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const el = $(selector);
|
const el = $(selector);
|
||||||
const opKeys = Object.keys(operators);
|
|
||||||
|
|
||||||
if (opKeys.length === 0) {
|
|
||||||
if (attr !== undefined) {
|
|
||||||
if (el.attr(attr) === undefined) {
|
|
||||||
return {
|
|
||||||
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
|
|
||||||
matched: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { failure: null, matched: true };
|
|
||||||
}
|
|
||||||
if (el.length === 0) {
|
|
||||||
return {
|
|
||||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
|
||||||
matched: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { failure: null, matched: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operators.exists === true) {
|
|
||||||
if (el.length === 0) {
|
|
||||||
return {
|
|
||||||
failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`),
|
|
||||||
matched: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { failure: null, matched: true };
|
|
||||||
}
|
|
||||||
if (operators.exists === false) {
|
if (operators.exists === false) {
|
||||||
if (el.length > 0) {
|
if (el.length > 0) {
|
||||||
return {
|
return {
|
||||||
@@ -75,13 +55,28 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (el.length === 0) {
|
if (el.length === 0) {
|
||||||
|
const expected = operators.exists === true ? true : "element found";
|
||||||
|
const actual = operators.exists === true ? false : "no match";
|
||||||
return {
|
return {
|
||||||
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
|
failure: mismatchFailure("body", fullPath, expected, actual, `selector ${selector} not found`),
|
||||||
matched: false,
|
matched: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (operators.exists === true) return { failure: null, matched: true };
|
||||||
|
|
||||||
const actual = attr ? el.attr(attr) : el.text();
|
const actual = attr ? el.attr(attr) : el.text();
|
||||||
|
const opKeys = Object.keys(operators);
|
||||||
|
if (opKeys.length === 0) {
|
||||||
|
if (actual === undefined) {
|
||||||
|
return {
|
||||||
|
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { failure: null, matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
const matched = applyOperator(actual ?? "", operators);
|
const matched = applyOperator(actual ?? "", operators);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
return {
|
return {
|
||||||
@@ -92,21 +87,19 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
|
|||||||
return { failure: null, matched: true };
|
return { failure: null, matched: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectResult {
|
function checkJsonRule(body: string, rule: JsonRule, rulePath: string, parsedJson?: ParsedJsonResult): ExpectResult {
|
||||||
const { path, ...operators } = rule;
|
const { path, ...operators } = rule;
|
||||||
const fullPath = `${rulePath}.json(${path})`;
|
const fullPath = `${rulePath}.json(${path})`;
|
||||||
|
|
||||||
let json: unknown;
|
const jsonResult = parsedJson ?? parseJsonBody(body);
|
||||||
try {
|
if (!jsonResult.ok) {
|
||||||
json = JSON.parse(body);
|
|
||||||
} catch {
|
|
||||||
return {
|
return {
|
||||||
failure: errorFailure("body", fullPath, "body is not valid JSON"),
|
failure: errorFailure("body", fullPath, jsonResult.error),
|
||||||
matched: false,
|
matched: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const actual = evaluateJsonPath(json, path);
|
const actual = evaluateJsonPath(jsonResult.value, path);
|
||||||
const opKeys = Object.keys(operators);
|
const opKeys = Object.keys(operators);
|
||||||
|
|
||||||
if (opKeys.length === 0) {
|
if (opKeys.length === 0) {
|
||||||
@@ -129,7 +122,7 @@ function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectRe
|
|||||||
return { failure: null, matched: true };
|
return { failure: null, matched: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkSingleBodyRule(body: string, rule: BodyRule, index: number): ExpectResult {
|
function checkSingleBodyRule(body: string, rule: BodyRule, index: number, parsedJson?: ParsedJsonResult): ExpectResult {
|
||||||
const rulePath = `body[${index}]`;
|
const rulePath = `body[${index}]`;
|
||||||
|
|
||||||
if ("contains" in rule) {
|
if ("contains" in rule) {
|
||||||
@@ -155,7 +148,7 @@ function checkSingleBodyRule(body: string, rule: BodyRule, index: number): Expec
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ("json" in rule) {
|
if ("json" in rule) {
|
||||||
return checkJsonRule(body, rule.json, rulePath);
|
return checkJsonRule(body, rule.json, rulePath, parsedJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("css" in rule) {
|
if ("css" in rule) {
|
||||||
@@ -208,3 +201,11 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect
|
|||||||
}
|
}
|
||||||
return { failure: null, matched: true };
|
return { failure: null, matched: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonBody(body: string): ParsedJsonResult {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(body) as unknown };
|
||||||
|
} catch {
|
||||||
|
return { error: "body is not valid JSON", ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,24 +53,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
|||||||
|
|
||||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||||
|
|
||||||
if (hasBodyRules && expect?.maxDurationMs !== undefined) {
|
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.maxDurationMs) : null;
|
||||||
const elapsed = performance.now() - start;
|
if (earlyTimeout) {
|
||||||
if (elapsed > expect.maxDurationMs) {
|
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
|
||||||
const durationMs = Math.round(elapsed);
|
|
||||||
return makeResult(
|
|
||||||
t,
|
|
||||||
timestamp,
|
|
||||||
elapsed,
|
|
||||||
mismatchFailure(
|
|
||||||
"duration",
|
|
||||||
"duration",
|
|
||||||
`<=${expect.maxDurationMs}ms`,
|
|
||||||
durationMs,
|
|
||||||
`duration ${durationMs}ms > ${expect.maxDurationMs}ms`,
|
|
||||||
),
|
|
||||||
statusCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasBodyRules) {
|
if (hasBodyRules) {
|
||||||
@@ -203,6 +188,28 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
|
|||||||
return newInit;
|
return newInit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkEarlyTimeout(
|
||||||
|
start: number,
|
||||||
|
maxDurationMs: number | undefined,
|
||||||
|
): null | { elapsed: number; failure: CheckResult["failure"] } {
|
||||||
|
if (maxDurationMs === undefined) return null;
|
||||||
|
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
if (elapsed <= maxDurationMs) return null;
|
||||||
|
|
||||||
|
const durationMs = Math.round(elapsed);
|
||||||
|
return {
|
||||||
|
elapsed,
|
||||||
|
failure: mismatchFailure(
|
||||||
|
"duration",
|
||||||
|
"duration",
|
||||||
|
`<=${maxDurationMs}ms`,
|
||||||
|
durationMs,
|
||||||
|
`duration ${durationMs}ms > ${maxDurationMs}ms`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function decodeBody(
|
function decodeBody(
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as xpath from "xpath";
|
|||||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||||
import type { CheckerValidationInput } from "../types";
|
import type { CheckerValidationInput } from "../types";
|
||||||
|
|
||||||
|
import { isUnsafeRegex } from "../../expect/redos";
|
||||||
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
|
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
|
||||||
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
|
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
|
||||||
import { issue, joinPath } from "../../schema/issues";
|
import { issue, joinPath } from "../../schema/issues";
|
||||||
@@ -188,10 +189,10 @@ function validateRegexRule(rule: unknown, path: string, targetName?: string): Co
|
|||||||
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||||
try {
|
try {
|
||||||
new RegExp(rule);
|
new RegExp(rule);
|
||||||
return [];
|
|
||||||
} catch {
|
} catch {
|
||||||
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
||||||
}
|
}
|
||||||
|
return isUnsafeRegex(rule) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
|
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
|
||||||
import type { StoredCheckResult } from "./checker/types";
|
import type { StoredCheckResult } from "./checker/types";
|
||||||
|
|
||||||
export function allowsGetHead(method: string): boolean {
|
|
||||||
return method === "GET" || method === "HEAD";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createApiError(error: string, status: number): ApiErrorResponse {
|
export function createApiError(error: string, status: number): ApiErrorResponse {
|
||||||
return { error, status };
|
return { error, status };
|
||||||
}
|
}
|
||||||
@@ -36,15 +32,14 @@ export function formatDuration(ms: number): string {
|
|||||||
|
|
||||||
export function jsonResponse(
|
export function jsonResponse(
|
||||||
body: unknown,
|
body: unknown,
|
||||||
options: { headers?: HeadersInit; method?: string; mode: RuntimeMode; status?: number },
|
options: { headers?: HeadersInit; mode: RuntimeMode; status?: number },
|
||||||
): Response {
|
): Response {
|
||||||
const headers = createHeaders(options.mode, {
|
const headers = createHeaders(options.mode, {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
...options.headers,
|
...options.headers,
|
||||||
});
|
});
|
||||||
const responseBody = options.method === "HEAD" ? null : JSON.stringify(body);
|
|
||||||
|
|
||||||
return new Response(responseBody, {
|
return new Response(JSON.stringify(body), {
|
||||||
headers,
|
headers,
|
||||||
status: options.status,
|
status: options.status,
|
||||||
});
|
});
|
||||||
@@ -69,11 +64,3 @@ export function mapCheckResult(row: StoredCheckResult): CheckResult {
|
|||||||
timestamp: row.timestamp,
|
timestamp: row.timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function methodNotAllowedResponse(allow: string[], mode: RuntimeMode): Response {
|
|
||||||
return jsonResponse(createApiError("Method not allowed", 405), {
|
|
||||||
headers: { Allow: allow.join(", ") },
|
|
||||||
mode,
|
|
||||||
status: 405,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
12
src/server/main.ts
Normal file
12
src/server/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { bootstrap } from "./bootstrap";
|
||||||
|
import { readRuntimeConfig } from "./config";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { configPath } = readRuntimeConfig();
|
||||||
|
await bootstrap({ configPath, mode: "production" });
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error) => {
|
||||||
|
console.error("启动失败:", error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,16 +1,9 @@
|
|||||||
import type { RuntimeMode } from "../shared/api";
|
import type { RuntimeMode } from "../shared/api";
|
||||||
|
|
||||||
import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers";
|
import { createApiError, jsonResponse } from "./helpers";
|
||||||
|
|
||||||
const MAX_PAGE_SIZE = 200;
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
|
||||||
export function guardGetHead(method: string, mode: RuntimeMode): null | Response {
|
|
||||||
if (!allowsGetHead(method)) {
|
|
||||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validatePagination(
|
export function validatePagination(
|
||||||
pageParam: null | string,
|
pageParam: null | string,
|
||||||
pageSizeParam: null | string,
|
pageSizeParam: null | string,
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import type { RuntimeMode } from "../../shared/api";
|
import type { RuntimeMode } from "../../shared/api";
|
||||||
|
|
||||||
import { allowsGetHead, createHealthResponse, jsonResponse, methodNotAllowedResponse } from "../helpers";
|
import { createHealthResponse, jsonResponse } from "../helpers";
|
||||||
|
|
||||||
export function handleHealth(method: string, mode: RuntimeMode): Response {
|
export function handleHealth(mode: RuntimeMode): Response {
|
||||||
if (!allowsGetHead(method)) {
|
return jsonResponse(createHealthResponse(), { mode });
|
||||||
return methodNotAllowedResponse(["GET", "HEAD"], mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse(createHealthResponse(), { method, mode });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import type { ProbeStore } from "../checker/store";
|
|||||||
import { jsonResponse, mapCheckResult } from "../helpers";
|
import { jsonResponse, mapCheckResult } from "../helpers";
|
||||||
import { validatePagination, validateTargetId, validateTimeRange } from "../middleware";
|
import { validatePagination, validateTargetId, validateTimeRange } from "../middleware";
|
||||||
|
|
||||||
export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
export function handleHistory(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response {
|
||||||
const idResult = validateTargetId(idStr, mode);
|
const idResult = validateTargetId(idStr, mode);
|
||||||
if (idResult instanceof Response) return idResult;
|
if (idResult instanceof Response) return idResult;
|
||||||
|
|
||||||
const target = store.getTargetById(idResult.id);
|
const target = store.getTargetById(idResult.id);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 });
|
return jsonResponse({ error: "Target not found", status: 404 } as const, { mode, status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
|
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
|
||||||
@@ -27,5 +27,5 @@ export function handleHistory(idStr: string, url: URL, method: string, store: Pr
|
|||||||
total: result.total,
|
total: result.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response, { method, mode });
|
return jsonResponse(response, { mode });
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/server/routes/meta.ts
Normal file
12
src/server/routes/meta.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { MetaResponse, RuntimeMode } from "../../shared/api";
|
||||||
|
|
||||||
|
import { checkerRegistry } from "../checker/runner";
|
||||||
|
import { jsonResponse } from "../helpers";
|
||||||
|
|
||||||
|
export function handleMeta(mode: RuntimeMode): Response {
|
||||||
|
const response: MetaResponse = {
|
||||||
|
checkerTypes: checkerRegistry.supportedTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return jsonResponse(response, { mode });
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import type { ProbeStore } from "../checker/store";
|
|||||||
|
|
||||||
import { jsonResponse } from "../helpers";
|
import { jsonResponse } from "../helpers";
|
||||||
|
|
||||||
export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response {
|
export function handleSummary(store: ProbeStore, mode: RuntimeMode): Response {
|
||||||
const summary = store.getSummary();
|
const summary = store.getSummary();
|
||||||
const response: SummaryResponse = {
|
const response: SummaryResponse = {
|
||||||
down: summary.down,
|
down: summary.down,
|
||||||
@@ -12,5 +12,5 @@ export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMo
|
|||||||
up: summary.up,
|
up: summary.up,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response, { method, mode });
|
return jsonResponse(response, { mode });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ProbeStore } from "../checker/store";
|
|||||||
|
|
||||||
import { formatDuration, jsonResponse, mapCheckResult } from "../helpers";
|
import { formatDuration, jsonResponse, mapCheckResult } from "../helpers";
|
||||||
|
|
||||||
export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response {
|
export function handleTargets(store: ProbeStore, mode: RuntimeMode): Response {
|
||||||
const targets = store.getTargets();
|
const targets = store.getTargets();
|
||||||
const latestChecksMap = store.getLatestChecksMap();
|
const latestChecksMap = store.getLatestChecksMap();
|
||||||
const allStats = store.getAllTargetStats();
|
const allStats = store.getAllTargetStats();
|
||||||
@@ -34,5 +34,5 @@ export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMo
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(result, { method, mode });
|
return jsonResponse(result, { mode });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import type { ProbeStore } from "../checker/store";
|
|||||||
import { jsonResponse } from "../helpers";
|
import { jsonResponse } from "../helpers";
|
||||||
import { validateTargetId, validateTimeRange } from "../middleware";
|
import { validateTargetId, validateTimeRange } from "../middleware";
|
||||||
|
|
||||||
export function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
export function handleTrend(idStr: string, url: URL, store: ProbeStore, mode: RuntimeMode): Response {
|
||||||
const idResult = validateTargetId(idStr, mode);
|
const idResult = validateTargetId(idStr, mode);
|
||||||
if (idResult instanceof Response) return idResult;
|
if (idResult instanceof Response) return idResult;
|
||||||
|
|
||||||
const target = store.getTargetById(idResult.id);
|
const target = store.getTargetById(idResult.id);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return jsonResponse({ error: "Target not found", status: 404 } as const, { method, mode, status: 404 });
|
return jsonResponse({ error: "Target not found", status: 404 } as const, { mode, status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
|
const timeResult = validateTimeRange(url.searchParams.get("from"), url.searchParams.get("to"), mode);
|
||||||
@@ -23,5 +23,5 @@ export function handleTrend(idStr: string, url: URL, method: string, store: Prob
|
|||||||
totalChecks: row.totalChecks,
|
totalChecks: row.totalChecks,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return jsonResponse(trend, { method, mode });
|
return jsonResponse(trend, { mode });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,54 @@
|
|||||||
import type { RuntimeMode } from "../shared/api";
|
import type { RuntimeMode } from "../shared/api";
|
||||||
import type { StaticAssets } from "./app";
|
|
||||||
import type { ProbeStore } from "./checker/store";
|
import type { ProbeStore } from "./checker/store";
|
||||||
import type { RuntimeConfig } from "./config";
|
import type { RuntimeConfig } from "./config";
|
||||||
|
|
||||||
import { createFetchHandler } from "./app";
|
import homepage from "../web/index.html";
|
||||||
|
import { createApiError, jsonResponse } from "./helpers";
|
||||||
|
import { handleHealth } from "./routes/health";
|
||||||
|
import { handleHistory } from "./routes/history";
|
||||||
|
import { handleMeta } from "./routes/meta";
|
||||||
|
import { handleSummary } from "./routes/summary";
|
||||||
|
import { handleTargets } from "./routes/targets";
|
||||||
|
import { handleTrend } from "./routes/trend";
|
||||||
|
|
||||||
export interface StartServerOptions {
|
export interface StartServerOptions {
|
||||||
config: RuntimeConfig;
|
config: RuntimeConfig;
|
||||||
mode: RuntimeMode;
|
mode: RuntimeMode;
|
||||||
staticAssets?: StaticAssets;
|
store: ProbeStore;
|
||||||
store?: ProbeStore;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startServer(options: StartServerOptions) {
|
export function startServer(options: StartServerOptions) {
|
||||||
const { config, mode, staticAssets, store } = options;
|
const { config, mode, store } = options;
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
fetch: createFetchHandler({
|
development: mode === "development" ? { console: true, hmr: true } : false,
|
||||||
mode,
|
fetch() {
|
||||||
staticAssets,
|
return new Response("Not found", { status: 404 });
|
||||||
store,
|
},
|
||||||
}),
|
|
||||||
hostname: config.host,
|
hostname: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
|
routes: {
|
||||||
|
"/*": homepage,
|
||||||
|
"/api/*": () => jsonResponse(createApiError("API route not found", 404), { mode, status: 404 }),
|
||||||
|
"/api/meta": {
|
||||||
|
GET: () => handleMeta(mode),
|
||||||
|
},
|
||||||
|
"/api/summary": {
|
||||||
|
GET: () => handleSummary(store, mode),
|
||||||
|
},
|
||||||
|
"/api/targets": {
|
||||||
|
GET: () => handleTargets(store, mode),
|
||||||
|
},
|
||||||
|
"/api/targets/:id/history": {
|
||||||
|
GET: (req) => handleHistory(req.params.id, new URL(req.url), store, mode),
|
||||||
|
},
|
||||||
|
"/api/targets/:id/trend": {
|
||||||
|
GET: (req) => handleTrend(req.params.id, new URL(req.url), store, mode),
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
GET: () => handleHealth(mode),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`DiAL listening on ${server.url}`);
|
console.log(`DiAL listening on ${server.url}`);
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { RuntimeMode } from "../shared/api";
|
|
||||||
import type { StaticAssets } from "./app";
|
|
||||||
|
|
||||||
import { createHeaders } from "./helpers";
|
|
||||||
|
|
||||||
export function contentTypeFor(pathname: string): string {
|
|
||||||
if (pathname.endsWith(".js") || pathname.endsWith(".mjs")) return "text/javascript; charset=utf-8";
|
|
||||||
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
|
|
||||||
if (pathname.endsWith(".svg")) return "image/svg+xml";
|
|
||||||
if (pathname.endsWith(".json")) return "application/json; charset=utf-8";
|
|
||||||
if (pathname.endsWith(".png")) return "image/png";
|
|
||||||
if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg";
|
|
||||||
if (pathname.endsWith(".ico")) return "image/x-icon";
|
|
||||||
|
|
||||||
return "application/octet-stream";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasFileExtension(pathname: string): boolean {
|
|
||||||
return /\/[^/]+\.[^/]+$/.test(pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function htmlResponse(indexHtml: Blob, mode: RuntimeMode): Response {
|
|
||||||
return new Response(indexHtml, {
|
|
||||||
headers: createHeaders(mode, {
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Content-Type": "text/html; charset=utf-8",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serveStaticAsset(pathname: string, staticAssets: StaticAssets, mode: RuntimeMode): Response {
|
|
||||||
if (pathname === "/") {
|
|
||||||
return htmlResponse(staticAssets.indexHtml, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const asset = staticAssets.files[pathname];
|
|
||||||
|
|
||||||
if (asset) {
|
|
||||||
return new Response(asset, {
|
|
||||||
headers: createHeaders(mode, {
|
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
|
||||||
"Content-Type": contentTypeFor(pathname),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith("/assets/") || hasFileExtension(pathname)) {
|
|
||||||
return new Response("Not Found", {
|
|
||||||
headers: createHeaders(mode, { "Content-Type": "text/plain; charset=utf-8" }),
|
|
||||||
status: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return htmlResponse(staticAssets.indexHtml, mode);
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,10 @@ export interface HistoryResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MetaResponse {
|
||||||
|
checkerTypes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecentSample {
|
export interface RecentSample {
|
||||||
durationMs: null | number;
|
durationMs: null | number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Alert, Loading, Typography } from "tdesign-react";
|
|||||||
import { SummaryCards } from "./components/SummaryCards";
|
import { SummaryCards } from "./components/SummaryCards";
|
||||||
import { TargetBoard } from "./components/TargetBoard";
|
import { TargetBoard } from "./components/TargetBoard";
|
||||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||||
import { useSummary, useTargetDetail, useTargets } from "./hooks/useTargetDetail";
|
import { useSummary, useTargets } from "./hooks/use-queries";
|
||||||
|
import { useTargetDetail } from "./hooks/use-target-detail";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { data: summary, error: summaryError, isLoading: summaryLoading } = useSummary();
|
const { data: summary, error: summaryError, isLoading: summaryLoading } = useSummary();
|
||||||
|
|||||||
31
src/web/components/HistoryTab.tsx
Normal file
31
src/web/components/HistoryTab.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { PrimaryTable } from "tdesign-react";
|
||||||
|
|
||||||
|
import type { HistoryResponse } from "../../shared/api";
|
||||||
|
|
||||||
|
import { HISTORY_COLUMNS } from "../constants/history-table-columns";
|
||||||
|
|
||||||
|
interface HistoryTabProps {
|
||||||
|
historyData: HistoryResponse;
|
||||||
|
historyLoading: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryTab({ historyData, historyLoading, onPageChange }: HistoryTabProps) {
|
||||||
|
return (
|
||||||
|
<PrimaryTable
|
||||||
|
columns={HISTORY_COLUMNS}
|
||||||
|
data={historyData.items}
|
||||||
|
disableDataPage
|
||||||
|
loading={historyLoading}
|
||||||
|
onPageChange={({ current }) => {
|
||||||
|
if (current) onPageChange(current);
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
current: historyData.page,
|
||||||
|
pageSize: historyData.pageSize,
|
||||||
|
total: historyData.total,
|
||||||
|
}}
|
||||||
|
rowKey="timestamp"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/web/components/OverviewTab.tsx
Normal file
57
src/web/components/OverviewTab.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
||||||
|
|
||||||
|
import type { TargetStatus, TrendPoint } from "../../shared/api";
|
||||||
|
|
||||||
|
import { computeTrendStats } from "../utils/stats";
|
||||||
|
import { StatusDonut } from "./StatusDonut";
|
||||||
|
import { TrendChart } from "./TrendChart";
|
||||||
|
|
||||||
|
interface OverviewTabProps {
|
||||||
|
target: TargetStatus;
|
||||||
|
trendData: TrendPoint[];
|
||||||
|
trendLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab({ target, trendData, trendLoading }: OverviewTabProps) {
|
||||||
|
const { downChecks, totalChecks, upChecks } = useMemo(() => computeTrendStats(trendData), [trendData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space className="full-width" direction="vertical" size={16}>
|
||||||
|
<Divider align="left">统计</Divider>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={3}>
|
||||||
|
<Statistic color="blue" title="总检查" value={totalChecks} />
|
||||||
|
</Col>
|
||||||
|
<Col span={3}>
|
||||||
|
<Statistic color="green" title="正常" value={upChecks} />
|
||||||
|
</Col>
|
||||||
|
<Col span={3}>
|
||||||
|
<Statistic color="red" title="异常" value={downChecks} />
|
||||||
|
</Col>
|
||||||
|
<Col span={3}>
|
||||||
|
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Divider align="left">趋势</Divider>
|
||||||
|
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} />}
|
||||||
|
|
||||||
|
<Divider align="left">状态分布</Divider>
|
||||||
|
<StatusDonut down={downChecks} up={upChecks} />
|
||||||
|
|
||||||
|
<Divider align="left">基本信息</Divider>
|
||||||
|
<Descriptions
|
||||||
|
items={[
|
||||||
|
{ content: target.target, label: "目标地址" },
|
||||||
|
{ content: target.interval, label: "检查间隔" },
|
||||||
|
{
|
||||||
|
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||||
|
label: "最新检查时间",
|
||||||
|
},
|
||||||
|
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
interface StatusBarProps {
|
interface StatusBarProps {
|
||||||
|
maxSlots?: number;
|
||||||
samples: Array<{ up: boolean }>;
|
samples: Array<{ up: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBar({ samples }: StatusBarProps) {
|
export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < maxSlots; i++) {
|
||||||
const sample = samples[i];
|
const sample = samples[i];
|
||||||
if (sample) {
|
if (sample) {
|
||||||
blocks.push(
|
blocks.push(
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export function StatusDonut({ down, up }: StatusDonutProps) {
|
|||||||
<ResponsiveContainer height={180} width="100%">
|
<ResponsiveContainer height={180} width="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
|
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
|
||||||
{data.map((_, index) => (
|
{data.map((item, index) => (
|
||||||
<Cell fill={colors[index % colors.length]} key={index} />
|
<Cell fill={colors[index % colors.length]} key={item.name} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { Space } from "tdesign-react";
|
import { Space } from "tdesign-react";
|
||||||
|
|
||||||
import type { TargetStatus } from "../../shared/api";
|
import type { TargetStatus } from "../../shared/api";
|
||||||
|
|
||||||
|
import { createTargetTableColumns } from "../constants/target-table-columns";
|
||||||
|
import { useMeta } from "../hooks/use-queries";
|
||||||
import { TargetGroup } from "./TargetGroup";
|
import { TargetGroup } from "./TargetGroup";
|
||||||
|
|
||||||
|
const EMPTY_CHECKER_TYPES: string[] = [];
|
||||||
|
|
||||||
interface TargetBoardProps {
|
interface TargetBoardProps {
|
||||||
onTargetClick: (target: TargetStatus) => void;
|
onTargetClick: (target: TargetStatus) => void;
|
||||||
targets: TargetStatus[];
|
targets: TargetStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
||||||
|
const { data: meta } = useMeta();
|
||||||
|
const checkerTypes = meta?.checkerTypes ?? EMPTY_CHECKER_TYPES;
|
||||||
|
const columns = useMemo(() => createTargetTableColumns(checkerTypes), [checkerTypes]);
|
||||||
|
|
||||||
const groups = new Map<string, TargetStatus[]>();
|
const groups = new Map<string, TargetStatus[]>();
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const group = target.group;
|
const group = target.group;
|
||||||
@@ -29,7 +38,7 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
|||||||
return (
|
return (
|
||||||
<Space className="full-width" direction="vertical" size={32}>
|
<Space className="full-width" direction="vertical" size={32}>
|
||||||
{sortedGroups.map(([name, groupTargets]) => (
|
{sortedGroups.map(([name, groupTargets]) => (
|
||||||
<TargetGroup key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
<TargetGroup columns={columns} key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,30 +1,14 @@
|
|||||||
import type { TabValue } from "tdesign-react";
|
import type { TabValue } from "tdesign-react";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import {
|
import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } from "tdesign-react";
|
||||||
Col,
|
|
||||||
DateRangePicker,
|
|
||||||
Descriptions,
|
|
||||||
Divider,
|
|
||||||
Drawer,
|
|
||||||
PrimaryTable,
|
|
||||||
RadioGroup,
|
|
||||||
Row,
|
|
||||||
Skeleton,
|
|
||||||
Space,
|
|
||||||
Statistic,
|
|
||||||
Tabs,
|
|
||||||
Tag,
|
|
||||||
Typography,
|
|
||||||
} from "tdesign-react";
|
|
||||||
|
|
||||||
import type { CheckResult, HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
import type { HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||||
|
|
||||||
import { getTargetTypeDisplay } from "../constants/target-type-display";
|
|
||||||
import { subtractHours } from "../utils/time";
|
import { subtractHours } from "../utils/time";
|
||||||
import { StatusDonut } from "./StatusDonut";
|
import { HistoryTab } from "./HistoryTab";
|
||||||
|
import { OverviewTab } from "./OverviewTab";
|
||||||
import { StatusDot } from "./StatusDot";
|
import { StatusDot } from "./StatusDot";
|
||||||
import { TrendChart } from "./TrendChart";
|
|
||||||
|
|
||||||
interface TargetDetailDrawerProps {
|
interface TargetDetailDrawerProps {
|
||||||
historyData: HistoryResponse;
|
historyData: HistoryResponse;
|
||||||
@@ -46,43 +30,6 @@ const TIME_SHORTCUTS = [
|
|||||||
{ hours: 168, label: "7天", value: "7d" },
|
{ hours: 168, label: "7天", value: "7d" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const HISTORY_COLUMNS = [
|
|
||||||
{
|
|
||||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => (
|
|
||||||
<StatusDot up={!!row.matched} />
|
|
||||||
),
|
|
||||||
colKey: "matched",
|
|
||||||
title: "#",
|
|
||||||
width: 40,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
|
|
||||||
const d = new Date(row.timestamp);
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
||||||
},
|
|
||||||
colKey: "timestamp",
|
|
||||||
title: "时间",
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
align: "center" as const,
|
|
||||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) =>
|
|
||||||
row.durationMs !== null ? Math.round(row.durationMs) : "-",
|
|
||||||
colKey: "durationMs",
|
|
||||||
title: "耗时(ms)",
|
|
||||||
width: 96,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cell: ({ row }: { col: unknown; colIndex: number; row: CheckResult; rowIndex: number }) => {
|
|
||||||
const parts = [row.statusDetail, row.failure?.message].filter(Boolean);
|
|
||||||
return parts.length > 0 ? parts.join(":") : "-";
|
|
||||||
},
|
|
||||||
colKey: "statusDetail",
|
|
||||||
title: "详情",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function TargetDetailDrawer({
|
export function TargetDetailDrawer({
|
||||||
historyData,
|
historyData,
|
||||||
historyLoading,
|
historyLoading,
|
||||||
@@ -123,9 +70,6 @@ export function TargetDetailDrawer({
|
|||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
|
|
||||||
const isUp = target.latestCheck?.matched;
|
const isUp = target.latestCheck?.matched;
|
||||||
const totalChecks = trendData.reduce((sum, p) => sum + p.totalChecks, 0);
|
|
||||||
const upChecks = trendData.reduce((sum, p) => sum + Math.round((p.availability / 100) * p.totalChecks), 0);
|
|
||||||
const downChecks = totalChecks - upChecks;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
@@ -135,7 +79,7 @@ export function TargetDetailDrawer({
|
|||||||
<StatusDot up={!!isUp} />
|
<StatusDot up={!!isUp} />
|
||||||
<Typography.Text strong>{target.name}</Typography.Text>
|
<Typography.Text strong>{target.name}</Typography.Text>
|
||||||
<Tag size="small" theme="primary" variant="light-outline">
|
<Tag size="small" theme="primary" variant="light-outline">
|
||||||
{getTargetTypeDisplay(target.type)}
|
{target.type}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
@@ -163,66 +107,16 @@ export function TargetDetailDrawer({
|
|||||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||||
valueType="YYYY-MM-DD HH:mm"
|
valueType="YYYY-MM-DD HH:mm"
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||||
<Space className="full-width" direction="vertical" size={16}>
|
<OverviewTab target={target} trendData={trendData} trendLoading={trendLoading} />
|
||||||
<Divider align="left">统计</Divider>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={3}>
|
|
||||||
<Statistic color="blue" title="总检查" value={totalChecks} />
|
|
||||||
</Col>
|
|
||||||
<Col span={3}>
|
|
||||||
<Statistic color="green" title="正常" value={upChecks} />
|
|
||||||
</Col>
|
|
||||||
<Col span={3}>
|
|
||||||
<Statistic color="red" title="异常" value={downChecks} />
|
|
||||||
</Col>
|
|
||||||
<Col span={3}>
|
|
||||||
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Divider align="left">趋势</Divider>
|
|
||||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} loading={false} />}
|
|
||||||
|
|
||||||
<Divider align="left">状态分布</Divider>
|
|
||||||
<StatusDonut down={downChecks} up={upChecks} />
|
|
||||||
|
|
||||||
<Divider align="left">基本信息</Divider>
|
|
||||||
<Descriptions
|
|
||||||
items={[
|
|
||||||
{ content: target.target, label: "目标地址" },
|
|
||||||
{ content: target.interval, label: "检查间隔" },
|
|
||||||
{
|
|
||||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
|
||||||
label: "最新检查时间",
|
|
||||||
},
|
|
||||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</Tabs.TabPanel>
|
</Tabs.TabPanel>
|
||||||
|
|
||||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
||||||
<PrimaryTable
|
<HistoryTab historyData={historyData} historyLoading={historyLoading} onPageChange={onPageChange} />
|
||||||
columns={HISTORY_COLUMNS}
|
|
||||||
data={historyData.items}
|
|
||||||
disableDataPage
|
|
||||||
loading={historyLoading}
|
|
||||||
onPageChange={({ current }) => {
|
|
||||||
if (current) onPageChange(current);
|
|
||||||
}}
|
|
||||||
pagination={{
|
|
||||||
current: historyData.page,
|
|
||||||
pageSize: historyData.pageSize,
|
|
||||||
total: historyData.total,
|
|
||||||
}}
|
|
||||||
rowKey="timestamp"
|
|
||||||
/>
|
|
||||||
</Tabs.TabPanel>
|
</Tabs.TabPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</Space>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
|
import type { PrimaryTableCol } from "tdesign-react";
|
||||||
|
|
||||||
import { PrimaryTable } from "tdesign-react";
|
import { PrimaryTable } from "tdesign-react";
|
||||||
|
|
||||||
import type { TargetStatus } from "../../shared/api";
|
import type { TargetStatus } from "../../shared/api";
|
||||||
|
|
||||||
import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns";
|
|
||||||
import { GroupHeader } from "./GroupHeader";
|
import { GroupHeader } from "./GroupHeader";
|
||||||
|
|
||||||
interface TargetGroupProps {
|
interface TargetGroupProps {
|
||||||
|
columns: Array<PrimaryTableCol<TargetStatus>>;
|
||||||
name: string;
|
name: string;
|
||||||
onTargetClick: (target: TargetStatus) => void;
|
onTargetClick: (target: TargetStatus) => void;
|
||||||
targets: TargetStatus[];
|
targets: TargetStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps) {
|
export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
|
||||||
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
||||||
const down = targets.length - up;
|
const down = targets.length - up;
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps)
|
|||||||
<PrimaryTable
|
<PrimaryTable
|
||||||
bordered
|
bordered
|
||||||
className="clickable-table"
|
className="clickable-table"
|
||||||
columns={TARGET_TABLE_COLUMNS}
|
columns={columns}
|
||||||
data={targets}
|
data={targets}
|
||||||
defaultSort={[{ descending: true, sortBy: "latestCheck.matched" }]}
|
defaultSort={[{ descending: true, sortBy: "latestCheck.matched" }]}
|
||||||
hover
|
hover
|
||||||
|
|||||||
@@ -4,14 +4,9 @@ import type { TrendPoint } from "../../shared/api";
|
|||||||
|
|
||||||
interface TrendChartProps {
|
interface TrendChartProps {
|
||||||
data: TrendPoint[];
|
data: TrendPoint[];
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TrendChart({ data, loading }: TrendChartProps) {
|
|
||||||
if (loading) {
|
|
||||||
return <div className="trend-loading">加载趋势数据...</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TrendChart({ data }: TrendChartProps) {
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return <div className="trend-empty">暂无趋势数据</div>;
|
return <div className="trend-empty">暂无趋势数据</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/web/constants/history-table-columns.tsx
Normal file
42
src/web/constants/history-table-columns.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react";
|
||||||
|
|
||||||
|
import type { CheckResult } from "../../shared/api";
|
||||||
|
|
||||||
|
import { StatusDot } from "../components/StatusDot";
|
||||||
|
|
||||||
|
export const HISTORY_COLUMNS: Array<PrimaryTableCol<CheckResult>> = [
|
||||||
|
{
|
||||||
|
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => <StatusDot up={!!row.matched} />,
|
||||||
|
colKey: "matched",
|
||||||
|
title: "#",
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => formatTimestamp(row.timestamp),
|
||||||
|
colKey: "timestamp",
|
||||||
|
title: "时间",
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
align: "center",
|
||||||
|
cell: ({ row }: PrimaryTableCellParams<CheckResult>) =>
|
||||||
|
row.durationMs !== null ? Math.round(row.durationMs) : "-",
|
||||||
|
colKey: "durationMs",
|
||||||
|
title: "耗时(ms)",
|
||||||
|
width: 96,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => {
|
||||||
|
const parts = [row.statusDetail, row.failure?.message].filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts.join(":") : "-";
|
||||||
|
},
|
||||||
|
colKey: "statusDetail",
|
||||||
|
title: "详情",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const pad = (value: number) => String(value).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ import type { TargetStatus } from "../../shared/api";
|
|||||||
import { StatusBar } from "../components/StatusBar";
|
import { StatusBar } from "../components/StatusBar";
|
||||||
import { StatusDot } from "../components/StatusDot";
|
import { StatusDot } from "../components/StatusDot";
|
||||||
import { getAvailabilityProgressColor } from "./color-threshold";
|
import { getAvailabilityProgressColor } from "./color-threshold";
|
||||||
import { statusFilter, typeFilter } from "./target-table-filters";
|
import { statusFilter } from "./target-table-filters";
|
||||||
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
||||||
import { getTargetTypeDisplay } from "./target-type-display";
|
|
||||||
|
|
||||||
export const TARGET_TABLE_COLUMNS: Array<PrimaryTableCol<TargetStatus>> = [
|
export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> {
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
align: "center",
|
align: "center",
|
||||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
||||||
@@ -31,11 +31,11 @@ export const TARGET_TABLE_COLUMNS: Array<PrimaryTableCol<TargetStatus>> = [
|
|||||||
{
|
{
|
||||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
||||||
<Tag size="small" theme="primary" variant="light-outline">
|
<Tag size="small" theme="primary" variant="light-outline">
|
||||||
{getTargetTypeDisplay(row.type)}
|
{row.type}
|
||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
colKey: "type",
|
colKey: "type",
|
||||||
filter: typeFilter,
|
filter: createTypeFilter(checkerTypes),
|
||||||
title: "类型",
|
title: "类型",
|
||||||
width: 80,
|
width: 80,
|
||||||
},
|
},
|
||||||
@@ -87,6 +87,11 @@ export const TARGET_TABLE_COLUMNS: Array<PrimaryTableCol<TargetStatus>> = [
|
|||||||
width: 72,
|
width: 72,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export { statusFilter, typeFilter } from "./target-table-filters";
|
function createTypeFilter(checkerTypes: string[]): PrimaryTableCol["filter"] {
|
||||||
export { availabilitySorter, latencySorter, nameSorter, statusSorter } from "./target-table-sorters";
|
return {
|
||||||
|
list: [{ label: "全部", value: "" }, ...checkerTypes.map((type) => ({ label: type, value: type }))],
|
||||||
|
type: "single",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,12 +8,3 @@ export const statusFilter: PrimaryTableCol["filter"] = {
|
|||||||
],
|
],
|
||||||
type: "single",
|
type: "single",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const typeFilter: PrimaryTableCol["filter"] = {
|
|
||||||
list: [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
{ label: "HTTP", value: "http" },
|
|
||||||
{ label: "CMD", value: "command" },
|
|
||||||
],
|
|
||||||
type: "single",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
export const TARGET_TYPE_DISPLAY = {
|
|
||||||
command: "CMD",
|
|
||||||
http: "HTTP",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type TargetType = keyof typeof TARGET_TYPE_DISPLAY;
|
|
||||||
|
|
||||||
export function getTargetTypeDisplay(type: string): string {
|
|
||||||
return TARGET_TYPE_DISPLAY[type as TargetType] || type.toUpperCase();
|
|
||||||
}
|
|
||||||
1
src/web/css.d.ts
vendored
Normal file
1
src/web/css.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
41
src/web/hooks/use-queries.ts
Normal file
41
src/web/hooks/use-queries.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import type { MetaResponse, SummaryResponse, TargetStatus } from "../../shared/api";
|
||||||
|
|
||||||
|
const queryKeys = {
|
||||||
|
meta: () => ["meta"] as const,
|
||||||
|
summary: () => ["summary"] as const,
|
||||||
|
targets: () => ["targets"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMeta() {
|
||||||
|
return useQuery({
|
||||||
|
queryFn: () => fetchJson<MetaResponse>("/api/meta"),
|
||||||
|
queryKey: queryKeys.meta(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSummary() {
|
||||||
|
return useQuery({
|
||||||
|
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||||
|
queryKey: queryKeys.summary(),
|
||||||
|
refetchInterval: 8000,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTargets() {
|
||||||
|
return useQuery({
|
||||||
|
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||||
|
queryKey: queryKeys.targets(),
|
||||||
|
refetchInterval: 8000,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,26 +1,16 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import type { HistoryResponse, SummaryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
import type { HistoryResponse, TargetStatus, TrendPoint } from "../../shared/api";
|
||||||
|
|
||||||
import { subtractHours } from "../utils/time";
|
import { subtractHours } from "../utils/time";
|
||||||
|
import { fetchJson, useTargets } from "./use-queries";
|
||||||
|
|
||||||
const queryKeys = {
|
const detailQueryKeys = {
|
||||||
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const,
|
||||||
summary: () => ["summary"] as const,
|
|
||||||
targets: () => ["targets"] as const,
|
|
||||||
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
trend: (targetId: number, from: string, to: string) => ["trend", targetId, from, to] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useSummary() {
|
|
||||||
return useQuery({
|
|
||||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
|
||||||
queryKey: queryKeys.summary(),
|
|
||||||
refetchInterval: 8000,
|
|
||||||
refetchIntervalInBackground: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTargetDetail() {
|
export function useTargetDetail() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
|
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
|
||||||
@@ -29,9 +19,8 @@ export function useTargetDetail() {
|
|||||||
const [historyPage, setHistoryPage] = useState(1);
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
|
|
||||||
const { data: targetsData } = useTargets();
|
const { data: targetsData } = useTargets();
|
||||||
|
|
||||||
const selectedTarget =
|
const selectedTarget =
|
||||||
selectedTargetId !== null ? (targetsData?.find((t) => t.id === selectedTargetId) ?? null) : null;
|
selectedTargetId !== null ? (targetsData?.find((target) => target.id === selectedTargetId) ?? null) : null;
|
||||||
|
|
||||||
const trend = useQuery({
|
const trend = useQuery({
|
||||||
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
|
||||||
@@ -41,7 +30,7 @@ export function useTargetDetail() {
|
|||||||
),
|
),
|
||||||
queryKey:
|
queryKey:
|
||||||
selectedTargetId !== null && timeFrom && timeTo
|
selectedTargetId !== null && timeFrom && timeTo
|
||||||
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
? detailQueryKeys.trend(selectedTargetId, timeFrom, timeTo)
|
||||||
: ["trend", "disabled"],
|
: ["trend", "disabled"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +42,7 @@ export function useTargetDetail() {
|
|||||||
),
|
),
|
||||||
queryKey:
|
queryKey:
|
||||||
selectedTargetId !== null && timeFrom && timeTo
|
selectedTargetId !== null && timeFrom && timeTo
|
||||||
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
? detailQueryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
|
||||||
: ["history", "disabled"],
|
: ["history", "disabled"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,18 +85,3 @@ export function useTargetDetail() {
|
|||||||
trendLoading: trend.isLoading,
|
trendLoading: trend.isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTargets() {
|
|
||||||
return useQuery({
|
|
||||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
|
||||||
queryKey: queryKeys.targets(),
|
|
||||||
refetchInterval: 8000,
|
|
||||||
refetchIntervalInBackground: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson<T>(url: string): Promise<T> {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user