1
0

Compare commits

..

5 Commits

Author SHA1 Message Date
0fa2c0c811 feat: 新增两个 OpenSpec 变更提案 — CMD Checker 增强与前端指标增强 2026-05-14 01:39:26 +08:00
6e485cc991 refactor: 迁移 Bun fullstack 架构 2026-05-14 00:23:37 +08:00
bcfac52112 refactor: HTTP checker 质量加固
- failure actual 截断格式改为 …(共 N 字符),标量不序列化直接返回
- 新增 redos.ts 实现 ReDoS 静态检测(嵌套量词/重叠交替),启动期拒绝危险正则
- JSON body rules 共享同一次 JSON.parse 结果,避免重复解析
- checkCssRule 重构为线性流程,消除 exist:true 与无 operator 的冗余分支
- extract checkEarlyTimeout 辅助函数,明确提前 duration 检查意图
- 补充 303/307/308 重定向、相对路径 Location、混合 body rules 集成测试
2026-05-13 21:35:05 +08:00
31aeee6d60 refactor: 前端架构重构 — hook拆分、组件拆分、类型筛选器动态化、Meta API
- 后端新增 GET /api/meta 端点(checkerRegistry.supportedTypes)及 MetaResponse 类型
- 前端 hook 拆分为 use-queries.ts(全局查询+useMeta)和 use-target-detail.ts(Drawer状态)
- TargetDetailDrawer 拆分为 OverviewTab + HistoryTab + history-table-columns + stats.ts
- 类型筛选器由 meta API 动态驱动,删除 target-type-display 静态映射
- 列定义改为工厂函数 createTargetTableColumns(checkerTypes),TargetGroup 新增 columns prop
- 修复 StatusDonut key、StatusBar maxSlots prop、TrendChart 移除 loading prop
- 补充 utils/time、utils/stats、动态列工厂测试,删除旧 mapping 测试
- 同步 delta specs 到主 specs,归档 frontend-architecture-refactor change
2026-05-13 20:55:42 +08:00
a62007083d chore: 归档 backend-architecture-hardening 变更并同步 delta spec 到主 spec 2026-05-13 19:50:33 +08:00
115 changed files with 2529 additions and 2664 deletions

View File

@@ -20,16 +20,16 @@
```text
src/
server/
app.ts Bun HTTP 路由入口(路由分发 + API 汇聚、StaticAssets 接口定义)
bootstrap.ts 后端统一启动引导loadConfig → store → engine → startServer → shutdown
config.ts CLI 参数解析(仅提取配置文件路径)
dev.ts 开发模式启动入口
server.ts HTTP server 启动工厂(接收 StartServerOptions
dev.ts 开发模式启动入口mode: "development"HMR 自动注入)
main.ts 生产模式启动入口mode: "production",安全头启用
server.ts HTTP server 启动工厂Bun.serve routes 声明式路由 + HTML import
helpers.ts 共享响应格式化工具(见下方函数清单)
middleware.ts API 参数校验中间件(guardGetHead、validateTargetId、validateTimeRange、validatePagination
static.ts 静态资源服务与 SPA fallback
routes/ API 路由 handler按端点拆分签名因端点而异
middleware.ts API 参数校验中间件validateTargetId、validateTimeRange、validatePagination
routes/ API 路由 handler按端点拆分
health.ts GET /health无 store 参数)
meta.ts GET /api/meta
summary.ts GET /api/summary
targets.ts GET /api/targets
history.ts GET /api/targets/:id/history
@@ -61,22 +61,24 @@ src/
command/ Command Checker自包含模块含 types/schema/execute/expect/validate/text
shared/
api.ts 前后端共享 TypeScript 类型
web/ Vite + React 前端 Dashboard
web/ React 前端 Dashboard(通过 Bun HTML import 集成)
app.tsx 根组件(编排全局状态与布局)
main.tsx 入口QueryClient 挂载 + ErrorBoundary + ReactQueryDevtools
styles.css 全局样式与自定义 CSS 变量
components/ UI 组件(见下方组件清单)
constants/ 常量与纯函数
target-type-display.ts 类型名称映射
target-table-columns.tsx 表格列定义
history-table-columns.tsx 历史记录表格列定义
target-table-columns.tsx 目标表格列定义工厂
target-table-filters.ts 表格筛选器
target-table-sorters.ts 表格排序器
color-threshold.ts 可用率颜色阈值函数
hooks/ TanStack Query 数据层
useTargetDetail.ts 集成轮询/条件查询的组合 hook
use-queries.ts 全局面板查询 hooksummary/targets/meta
use-target-detail.ts 目标详情 Drawer 状态与条件查询 hook
utils/ 前端工具函数
time.ts 时间处理subtractHours
scripts/ 开发、构建、schema 生成和 smoke test 脚本
stats.ts 趋势统计计算computeTrendStats
scripts/ 构建、schema 生成和清理脚本
tests/ Bun test 测试(结构镜像 src 目录)
openspec/ OpenSpec 变更与规格文档
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)
→ bootstrap({ configPath, mode, staticAssets? })
dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath)
→ bootstrap({ configPath, mode })
→ loadConfig(yaml) → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets }
→ ProbeStore(db) → store.syncTargets(targets)
→ ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start()
→ startServer({ config, mode, store, staticAssets? })
→ startServer({ config, mode, store })
→ 注册 SIGINT/SIGTERM shutdownengine.stop + store.close
运行时:
@@ -111,8 +113,9 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动
数据清理: 定时 prune(retentionMs),每小时执行一次
HTTP 请求:
Request → app.ts(路由分发) → routes/*.ts(handler)
Request → Bun.serve routes 声明式匹配 → routes/*.ts(handler)
→ middleware.ts(参数校验) → helpers.ts(响应格式化) → Response
前端: "/*": homepage (HTML import) → SPA fallback + HMR(开发模式)
```
### 1.2 库使用优先级
@@ -131,50 +134,63 @@ HTTP 请求:
### 1.3 API 路由开发
路由文件位于 `src/server/routes/`,每个端点一个文件。handler 函数签名因端点而异
路由文件位于 `src/server/routes/`,每个端点一个文件。路由通过 `server.ts``Bun.serve({ routes })` 声明式注册,使用 per-method handler 对象
```typescript
// 无 store 的路由(健康检查不依赖数据库)
export function handleHealth(method: string, mode: RuntimeMode): Response;
// server.ts 中的路由注册
routes: {
"/*": homepage, // HTML importSPA 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 的路由
export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMode): Response;
export function handleTargets(store: ProbeStore, method: string, mode: RuntimeMode): Response;
export function handleSummary(store: ProbeStore, mode: RuntimeMode): Response;
export function handleTargets(store: ProbeStore, mode: RuntimeMode): Response;
// 带 target ID 和查询参数的路由
export function handleHistory(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response;
export function handleTrend(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, store: ProbeStore, mode: RuntimeMode): Response;
```
**请求处理流程**
1. `app.ts``createFetchHandler` 作为总入口,根据 URL pattern 匹配路由
2. `/health` 路由独立处理,不经过 `guardGetHead`(使用 `helpers.ts``allowsGetHead` 自行校验方法
3. `/api/*` 路由统一经过 `guardGetHead` 做方法检查(仅允许 GET/HEAD返回 `null` 表示通过
4. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId``validateTimeRange``validatePagination` 做参数校验,`pageSize` 最大值为 `200`
5. 校验函数返回 `Response` 实例表示校验失败(直接返回),返回数据对象表示通过
6. 业务逻辑通过 `store` 查询数据,用 `helpers.ts``jsonResponse``mapCheckResult``formatDuration` 等格式化输出
1. `Bun.serve``routes` 对象按路径 + HTTP 方法匹配请求
2. 未匹配方法的请求落入 `/api/*` 通配符(返回 404
3. 各 handler 内部通过 `middleware.ts` 提供的 `validateTargetId``validateTimeRange``validatePagination` 做参数校验,`pageSize` 最大值为 `200`
4. 校验函数返回 `Response` 实例表示校验失败(直接返回),返回数据对象表示通过
5. 业务逻辑通过 `store` 查询数据,用 `helpers.ts``jsonResponse``mapCheckResult``formatDuration` 等格式化输出
**新增路由步骤**
1.`src/server/routes/` 下创建 `<name>.ts`
2. 实现 handler 函数并 export
3.`app.ts``createFetchHandler` 中注册路径匹配和调用
3.`server.ts``routes` 对象中注册路径和 method handler
4.`tests/server/app.test.ts` 中添加对应测试
### 1.4 共享工具
- **`helpers.ts`**:跨路由共用的响应工具函数
- `allowsGetHead(method)` — 判断是否为 GET/HEAD 方法
- `createApiError(error, status)` — 构造 API 错误体
- `createHeaders(mode, init)` — 创建响应 Headers生产模式附加安全头
- `createHealthResponse()` — 构造健康检查响应
- `formatDuration(ms)` — 毫秒转为可读时长字符串
- `jsonResponse(body, options)` — JSON 响应构造(自动处理 HEAD 空体)
- `jsonResponse(body, options)` — JSON 响应构造
- `mapCheckResult(row)` — 数据库行转 API CheckResult
- `methodNotAllowedResponse(allow, mode)` — 构造 405 响应
- **`middleware.ts`**API 参数校验函数(`guardGetHead``validateTargetId``validateTimeRange``validatePagination`,其中 `pageSize` 上限为 `200`
- **`static.ts`**:生产模式下的静态资源服务与 SPA fallback
- **`middleware.ts`**API 参数校验函数(`validateTargetId``validateTimeRange``validatePagination`,其中 `pageSize` 上限为 `200`
### 1.5 类型定义规范
@@ -358,14 +374,11 @@ TcpChecker implements Checker
| `engine.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `execute()` |
| `store.ts` | 按 `target.type` 从 registry 取对应 checker 执行 `serialize()` |
注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新前端展示常量、配置示例、文档和测试。
注意:自动适配指上述中间层不需要新增 `switch/case` 或类型分支;开发者仍需按后续步骤更新配置示例、文档和测试。
#### 1.7.7 步骤六:更新前端展示
#### 1.7.7 步骤六:确认前端类型展示
| 文件 | 修改内容 |
| ------------------------------------------- | ------------------------------------------------------------ |
| `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" }` |
前端通过 `/api/meta` 获取 `checkerRegistry.supportedTypes` 并动态生成类型筛选器,类型列和详情标题直接显示 `target.type` 原始文本。新增 checker 注册后无需更新前端类型映射或筛选常量。
#### 1.7.8 步骤七:编写测试
@@ -400,13 +413,11 @@ TcpChecker implements Checker
□ src/server/checker/runner/tcp/expect.ts — 专用断言(如需要)
□ src/server/checker/runner/tcp/index.ts — 模块入口re-export
□ src/server/checker/runner/index.ts — 注册(一行导入 + 一个数组元素)
□ src/web/constants/target-type-display.ts — 前端类型标签
□ src/web/constants/target-table-filters.ts — 前端类型筛选
□ tests/ — 契约 + 校验 + resolve + execute + 注册 测试
□ probes.example.yaml — 配置示例
□ bun run schema + bun run schema:check — Schema 导出同步
□ bun run check — 全量质量检查通过
□ bun run verify — 完整验证(含 build + smoke test
□ bun run verify — 完整验证(check + build
□ README.md — 用户文档
□ DEVELOPMENT.md — 项目结构目录树
```
@@ -486,18 +497,20 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
**Body 规则类型**`runner/http/body.ts`
- `contains`:文本包含匹配
- `regex`正则表达式匹配注意body 正则字段为 `regex`,不是 `match`
- `regex`正则表达式匹配注意body 正则字段为 `regex`,不是 `match`,启动期会拒绝嵌套量词等 ReDoS 风险模式
- `json`JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符)
- `css`cheerio CSS 选择器 + 操作符比较
- `xpath`XPath 节点提取 + 操作符比较
**文本规则**`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 错误模式
- **API 错误**`{ error: "描述", status: <code> }`,状态码 400/404/405/503
- **API 错误**`{ error: "描述", status: <code> }`,状态码 400/404/503
- **CheckFailure**`{ kind: "error"|"mismatch", phase, path, expected?, actual?, message }`
- **错误处理**expect 校验失败记录首个失败原因;网络/超时/进程崩溃统一为 `kind:"error"`,请求/TLS/timeout 错误归属 `phase:"request"`body 超限/解码/解析错误归属 `phase:"body"`
- **日志**:解析失败等非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)`
@@ -523,14 +536,14 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
| 层面 | 技术 | 用途 |
| ------ | --------------------------------------------------- | ---------------------------- |
| 框架 | React 19 | UI 组件开发 |
| 构建 | Vite 8 | 开发服务与生产构建 |
| 构建 | Bun HTML importfullstack 模式) | 开发服务与生产构建 |
| 语言 | TypeScript 6 | 类型安全 |
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 |
| 图表 | Recharts | 拨测趋势折线图与状态环状图 |
| 路由 | 无(单页面 Dashboard | 仅需 Drawer/Tab 做页面内导航 |
**不引入的依赖**React Router单页面场景不需要、状态管理库TanStack Query 即服务端状态层,组件内用 `useState` 足够)
**不引入的依赖**React Router单页面场景不需要、状态管理库TanStack Query 即服务端状态层,组件内用 `useState` 足够)、Vite已由 Bun 原生 fullstack 替代)
### 2.2 组件树与数据流
@@ -544,26 +557,29 @@ main.tsx
│ │ └── useSummary() ─── GET /api/summary8s 轮询)
│ └── TargetBoard目标列表
│ ├── useTargets() ─── GET /api/targets8s 轮询)
│ ├── useMeta() ────── GET /api/meta应用生命周期内缓存
│ └── TargetGroup[](按 group 字段分组)
│ └── PrimaryTable ← TARGET_TABLE_COLUMNS列定义排序/筛选/渲染)
│ └── PrimaryTable ← createTargetTableColumns(checkerTypes)
│ └── TargetDetailDrawer目标详情抽屉
│ └── useTargetDetail() ── 按需发起 trend + history 查询
│ ├── Tab: 概览 → Statistic + TrendChart + StatusDonut + Descriptions
│ └── Tab: 记录 → PrimaryTable分页历史记录
│ ├── OverviewTab → Statistic + TrendChart + StatusDonut + Descriptions
│ └── HistoryTab → PrimaryTable分页历史记录
└── ReactQueryDevtools开发工具仅开发环境
```
**数据层架构**
```
hooks/useTargetDetail.ts唯一的数据层入口
├── queryKeys结构化 query key,确保缓存粒度精确
hooks/use-queries.ts全局面板级查询
├── queryKeyssummary/targets/meta 结构化 query key
├── useSummary() → /api/summary8s 自动轮询)
├── useTargets() → /api/targets8s 自动轮询)
└── useTargetDetail()(组合 hook管理 Drawer 全部状态
├── 内部复用 useTargets() 的缓存来查找 selectedTarget
├── useQuery(/api/targets/:id/trend)条件查询enabled 仅当 Drawer 打开且时间范围有效
└── useQuery(/api/targets/:id/history)(条件查询:含分页)
└── useMeta() → /api/metastaleTime: Infinity
hooks/use-target-detail.tsDrawer 状态与详情级条件查询
├── 内部复用 useTargets() 的缓存来查找 selectedTarget
├── useQuery(/api/targets/:id/trend)条件查询enabled 仅当 Drawer 打开且时间范围有效)
└── useQuery(/api/targets/:id/history)(条件查询:含分页)
```
### 2.3 TanStack Query 数据层
@@ -574,6 +590,7 @@ hooks/useTargetDetail.ts唯一的数据层入口
const queryKeys = {
summary: () => ["summary"] as const,
targets: () => ["targets"] as const,
meta: () => ["meta"] 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,
};
@@ -668,7 +685,9 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) |
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 |
| `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 双轴折线图(耗时/可用率) |
| `StatusDonut` | `components/StatusDonut.tsx` | Recharts 环状图UP/DOWN 分布) |
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
@@ -682,7 +701,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
1. **确认数据需求**:是已有 API 数据还是需要新端点?
- 如有新端点,先在 `src/server/routes/` 添加,参考 [1.3 新增路由步骤](#13-api-路由开发)
- 如有新字段,更新 `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/` 创建组件文件
-`TargetDetailDrawer.tsx` 中新增 `<Tabs.TabPanel>` 引用
4. **编写常量**:如有列定义/排序器/筛选器,放在 `src/web/constants/`
@@ -724,81 +743,47 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
### 3.1 开发期运行
#### 同时启动前后端
```bash
bun run dev probes.yaml
```
`scripts/dev.ts` 通过 `Bun.spawn` 同时启动两个子进程
`bun --watch src/server/dev.ts` 启动单进程 fullstack 开发服务器
```
bun run dev probes.yaml
├── bun run dev:server probes.yaml → Bun HTTP 后端(默认 3000 端口)
└── bun run dev:web → Vite 前端开发服务器5173 端口)
```
- 任一子进程退出会导致整体退出
- `SIGINT`/`SIGTERM` 信号会同时终止两个子进程
- `BACKEND_PORT` 环境变量可覆盖后端端口
#### 分别启动
```bash
# 启动后端(含 watch 模式自动重启)
bun run dev:server probes.yaml
# 另开终端启动前端
bun run dev:web
```
- 后端 API + 前端 SPA 在同一端口(默认 3000
- `development` 模式自动注入 HMR前端修改即时热更新
- `--watch` 监听后端文件变更自动重启
- 访问 `http://127.0.0.1:3000` 即可使用完整应用
### 3.2 前后端集成方式
#### 开发期代理
#### 统一进程架构
Vite 配置了开发代理(`vite.config.ts`)和代码分割策略
前后端通过 Bun 的 HTML import 机制集成为单进程应用
```typescript
// 开发代理
server: {
proxy: {
"/api": {
target: `http://127.0.0.1:${backendPort}`,
changeOrigin: true,
// server.ts
import homepage from "../web/index.html";
const server = Bun.serve({
development: mode === "development" ? { hmr: true, console: true } : false,
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` 优先于 `/*`
#### 生产期集成
生产可执行文件是单体应用:前端静态资源嵌入 binary通过 `StaticAssets` 接口:`files: Record<string, Blob>` + `indexHtml: Blob`),后端同时提供 API 和静态文件服务。
```
./dist/dial-server probes.yaml
启动后:
访问 http://127.0.0.1:3000/ → 返回前端 SPAindex.html
访问 http://127.0.0.1:3000/api/* → 返回后端 API
访问 /assets/* → 返回带不可变缓存的静态资源
```
SPA fallback 逻辑(`src/server/static.ts`
- `/` → index.html
- 匹配 `/assets/*` → 返回对应文件(未匹配则 404
- 其他路径(如 `/dashboard`)→ fallback 到 index.htmlSPA 路由)
未匹配 method 的请求(如 POST /api/summary会落入 `/api/*` 通配符返回 404。
### 3.3 构建打包
@@ -808,33 +793,28 @@ SPA fallback 逻辑(`src/server/static.ts`
bun run build
```
#### 构建流程详解
#### 构建流程
`scripts/build.ts` 执行以下步骤
`scripts/build.ts` 执行单步构建
```
1. vite build
├── 入口src/web/index.html
└── 输出dist/web/index.html + assets/
2. 生成 .build/static-assets.ts临时文件
├── 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
Bun.build({
compile: { outfile: "dist/dial-server" },
entrypoints: ["src/server/main.ts"],
minify: true,
sourcemap: "linked",
})
```
- 入口为 `src/server/main.ts``mode: "production"`,启用安全头)
- HTML import 的前端资源自动打包进可执行文件Bun 自动生成 manifest
- 无需中间产物目录,一步生成最终 binary
#### 产物
| 产物 | 用途 |
| ------------------ | -------------------------- |
| `dist/dial-server` | 生产可执行文件 |
| `dist/web/` | Vite 构建产物(中间产物) |
| `.build/` | 临时生成文件(构建后清理) |
| ------------------ | ---------------------------------------- |
| `dist/dial-server` | 生产可执行文件(含前端资源,单文件部署) |
#### 构建参数
@@ -848,11 +828,17 @@ bun run build
./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
bun run clean
# 清理 dist/ 构建产物、.build/ 缓存和 *.bun-build 临时文件
# 清理 dist/ 构建产物和 *.bun-build 临时文件
```
### 3.4 开发工作流
@@ -860,8 +846,8 @@ bun run clean
#### 日常开发循环
```bash
bun run dev probes.yaml # 启动开发环境
# 修改代码 → Vite HMR前端/ bun --watch(后端自动重启
bun run dev probes.yaml # 启动开发环境(单进程,含 HMR
# 修改前端代码 → HMR 热更新 / 修改后端代码 → --watch 自动重启
bun run check # 提交前运行完整质量检查
```
@@ -869,43 +855,29 @@ bun run check # 提交前运行完整质量检查
```bash
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
bun run test:smoke
```
`scripts/smoke.ts` 构建后验证流程:
1. 动态分配空闲端口
2. 用临时配置文件启动 `dist/dial-server`
3. 等待健康检查通过
4. 验证所有 API 端点返回正确数据
5. 验证静态资源服务(含 SPA fallback 和 404 处理)
6. 验证安全 headers
7. 测试结束清理临时目录和进程
`scripts/smoke.ts` 覆盖过薄,已从当前工作流移除。后续如需验证 production executable 的 API、HTML import manifest、SPA fallback 和静态资源行为,应重新设计独立的 executable/E2E 测试。
### 3.6 脚本说明
| 脚本 | 文件 | 说明 |
| ---------------------- | ----------------------------------- | ------------------------------- |
| `bun run dev` | `scripts/dev.ts` | 同时启动前后端开发服务 |
| `bun run build` | `scripts/build.ts` | Vite 构建 + Bun 编译可执行文件 |
| ---------------------- | ----------------------------------- | ----------------------------------- |
| `bun run dev` | `src/server/dev.ts` | 单进程 fullstack 开发服务(含 HMR |
| `bun run build` | `scripts/build.ts` | Bun 编译可执行文件(含前端资源) |
| `bun run schema` | `scripts/generate-config-schema.ts` | 生成 `probe-config.schema.json` |
| `bun run schema:check` | `scripts/generate-config-schema.ts` | 检查配置 schema 导出物是否同步 |
| `bun run test:smoke` | `scripts/smoke.ts` | 构建后的端到端验证 |
| `bun run clean` | `scripts/clean.ts` | 清理构建缓存与临时文件 |
### 3.7 环境变量
| 变量 | 用途 | 默认值 |
| --------------------------- | ---------------------------------------------------- | -------- |
| `PORT`/`BACKEND_PORT` | 后端监听端口(开发期 Vite 代理目标、生产期监听端口) | `3000` |
| --------------------------- | ----------------------------------------------- | -------- |
| `BUN_TARGET`/`BUILD_TARGET` | 交叉编译目标平台(仅在 `bun run build` 时有效) | 当前平台 |
### 3.8 项目配置文件
@@ -914,7 +886,6 @@ bun run test:smoke
| ---------------------- | ---------------------------------------------- |
| `package.json` | 项目信息、脚本、依赖声明 |
| `tsconfig.json` | TypeScript 配置ESNext 模块、严格模式) |
| `vite.config.ts` | Vite 开发代理与构建配置(含代码分割策略) |
| `eslint.config.js` | ESLint 规则(含前端不得 import server 的检查) |
| `commitlint.config.js` | commitlint 提交信息格式校验 |
| `.prettierrc.json` | Prettier 格式化规则(`printWidth: 120` |
@@ -932,14 +903,13 @@ bun run test:smoke
### 3.10 目录约定
| 目录 | 约定 |
| ------------- | -------------------------------------------- |
| `src/server/` | 后端代码,不能 import `src/web/` |
| ------------- | ---------------------------------------------------- |
| `src/server/` | 后端代码,不能 import `src/web/`HTML import 除外) |
| `src/web/` | 前端代码,不能 import `src/server/` |
| `src/shared/` | 前后端共享类型,双向可引用 |
| `scripts/` | 独立运行脚本,可 import 项目源码 |
| `tests/` | 测试目录,结构镜像 src 目录 |
| `dist/` | 构建产物gitignore |
| `.build/` | 构建临时文件gitignore |
| `openspec/` | OpenSpec 变更管理与规格文档 |
| `data/` | 默认数据目录gitignore运行期生成 SQLite |
@@ -982,10 +952,10 @@ bun run check # 一键运行 schema:check + typecheck + lint + test
### TypeScript 严格标志
| 标志 | 值 | 说明 |
| ------------------------------------ | ----- | -------------------------------------------------------------------------- |
| ------------------------------------ | ----- | ------------------------------------------------ |
| `strict` | true | 全局严格模式 |
| `noUnusedLocals` | true | 未使用局部变量视为错误 |
| `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要,如 `handleXxx(store, method, mode)` |
| `noUnusedParameters` | false | 保留关闭(路由 handler 统一签名需要 |
| `noPropertyAccessFromIndexSignature` | true | 禁止通过点号访问索引签名属性,强制使用括号语法 |
| `noUncheckedIndexedAccess` | true | 数组/Map 访问必须运行时真值检查 |
| `noImplicitOverride` | true | 子类覆盖父类方法时必须显式使用 `override` 关键字 |
@@ -1008,7 +978,7 @@ bun run check # 一键运行 schema:check + typecheck + lint + test
```bash
bun run check # 日常开发(类型检查 + lint含格式 + 单元测试)
bun run verify # 完整验证check + 构建 + smoke test
bun run verify # 完整验证check + 构建)
```
## 已知限制

View File

@@ -10,15 +10,17 @@ cp probes.example.yaml 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
bun run dev:server probes.yaml
bun run dev:web
bun run check # schema:check + typecheck + lint + bun test
bun run verify # check + build
```
`verify` 会基于当前源码重新构建生产 executable。原 smoke test 已移除executable/E2E 验证后续单独补充。
## 配置文件
程序通过 YAML 配置文件定义所有运行参数:
@@ -134,7 +136,7 @@ targets:
- Command覆盖命令执行耗时含 stdout/stderr 读取)
- `body`: HTTP 响应体校验(数组,可组合使用)
- `contains`: 响应体包含的文本
- `regex`: 响应体匹配的正则表达式
- `regex`: 响应体匹配的正则表达式(启动期会拒绝嵌套量词等存在 ReDoS 风险的模式)
- `json`: JSONPath 提取值比较
- `path`: JSONPath 表达式(必填,如 `$.slideshow.title`
- 比较操作符(可选,无操作符时仅检查路径对应值是否存在)
@@ -146,7 +148,7 @@ targets:
- `path`: XPath 表达式(必填,如 `/html/body/h1/text()`
- 比较操作符(可选,无操作符时仅检查节点是否存在)
- `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`,也可直接使用数字(非负安全整数字节数)。
@@ -163,6 +165,7 @@ JSON Schema仓库根目录导出 `probe-config.schema.json`,可在 YAML 文
| 端点 | 说明 |
| ----------------------------------------------------------------- | ------------------------------------------------------------ |
| `GET /health` | 健康检查 |
| `GET /api/meta` | 运行时元信息checker 类型列表) |
| `GET /api/summary` | 总览统计total/up/down/lastCheckTime |
| `GET /api/targets` | 目标列表及最新状态、分组和采样数据 |
| `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`
**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`
@@ -197,8 +202,7 @@ API 错误返回 `ApiErrorResponse` 格式:
| 状态码 | 触发场景 |
| ------ | ------------------------------------------------------------------------------------------ |
| 400 | 参数格式错误(无效 ID、from/to 缺失或格式错误、page/pageSize 非正整数、pageSize 超过 200 |
| 404 | 目标不存在、API 路由未匹配 |
| 405 | 非 GET 方法请求 API 路由 |
| 404 | 目标不存在、API 路由未匹配、非 GET 方法请求 API 路由 |
## 运行参数

View File

@@ -26,7 +26,6 @@
"@types/bun": "^1.3.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@@ -40,7 +39,6 @@
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="],
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -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=="],
"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=="],
"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=="],
"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.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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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-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=="],
"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=="],
"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=="],
"vite": ["vite@8.0.11", "https://registry.npmmirror.com/vite/-/vite-8.0.11.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
@@ -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=="],
"@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=="],
"@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=="],
"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/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=="],

View File

@@ -1,121 +0,0 @@
## Context
DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型http、commandtarget 规模预计增长到 100checker 类型预计超过 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 signatureregistry 层仍用类型擦除)
- 不改变前端行为
## 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 类型。

View File

@@ -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_resultfailure 标记为 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 相关测试

View File

@@ -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** 计算 availabilityupCount / totalChecks * 100
- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致
#### Scenario: 目标无历史记录
- **WHEN** 某 target 在 check_results 表中无任何记录
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key

View File

@@ -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"]`(按注册顺序)

View File

@@ -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 状态码和错误信息

View File

@@ -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 解析为绝对路径

View File

@@ -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_resultfailure 为 `{ 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 个检查,其余检查等待并发槽位释放

View File

@@ -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 上限说明)

View 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 命令直观] → 加注释说明用途,保持可读性

View 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 列值变更(项目未上线,无迁移负担)

View File

@@ -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 分组

View File

@@ -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 保留原有有限整数数组语义,不限制到特定平台范围。

View File

@@ -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 字段

View File

@@ -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 能正常执行,不依赖平台特定命令

View 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 枚举、配置字段说明)

View File

@@ -0,0 +1,147 @@
## Context
DiAL 是一个基于 Bun 的全栈拨测监控系统,当前前端统计指标体系存在以下问题:
1. **计算逻辑缺陷**:可用率基于全量历史数据计算(`store.ts:getAllTargetStats` 无 WHERE 时间条件),随运行时间增长近期变化被稀释;`computeTrendStats` 从已截断的百分比反推整数有累积精度损失;`lastCheckTime` 已返回但前端未展示
2. **指标维度单一**Summary 仅 3 个计数卡片Drawer 统计区 4 个指标(总检查/正常/异常/可用率)本质是同一维度的冗余表达
3. **缺少关键运维指标**:无 P95 延迟、无 MTTR、无故障分析、无连续状态信息
当前技术栈:后端 Bun + SQLitebun: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 1P95 在应用层计算,不在 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 7Drawer 统计区 2×4 布局
**选择**:统计区和可靠性区合并为一个 2 行 × 4 列的 Statistic 网格。
```
┌────────────┬────────────┬────────────┬────────────┐
│ 可用率 │ 平均延迟 │ P95延迟 │ 检查总数 │
├────────────┼────────────┼────────────┼────────────┤
│ MTTR │ 最长故障 │ 故障次数 │ 连续正常 │
└────────────┴────────────┴────────────┴────────────┘
```
**理由**:原来的「总检查/正常/异常/可用率」4 指标信息冗余,正常/异常计数已在环形图中展示。重构后每个格子都是独立维度,信息密度大幅提升。
### Decision 8TrendPoint 增加 min/max 延迟字段
**选择**:在 SQL 聚合层直接计算 `MIN(duration_ms)``MAX(duration_ms)`,零额外成本。
**实现**:趋势图使用 recharts `<Area>` 组件渲染 min-max 范围(半透明品牌色填充),叠加 avg 实线。
### Decision 9Summary lastCheckTime 展示为相对时间
**选择**:在 Summary 区域底部展示 "最后更新: X秒前" 文本,前端每秒更新。
**实现**:使用 `useState` + `setInterval` 每秒计算相对时间差。超过 60 秒时文字变为警告色(--td-warning-color提示数据可能不新鲜。
### Decision 10StatusDonut 数据来源改为 statsData
**选择**StatusDonut 的 `up`/`down` 改为使用 `statsData.upChecks` / `statsData.downChecks`,不再从 trendData 反推。
**理由**statsData 的 upChecks 是精确值(直接从 SQL COUNT 返回),与统计区的"检查总数"一致,消除了之前从百分比反推的精度损失。
**影响**`computeTrendStats` 工具函数不再有调用方,直接删除。
### Decision 11MTTR 窗口边界截断处理
**选择**:如果时间窗口内第一条记录即为 matched=0故障跨越了 from 边界),该故障段不计入 MTTR 平均值,但计入 incidentCount。
**理由**:无法确定故障的真实开始时间,计入 MTTR 会低估实际恢复时间。incidentCount 计数是因为用户确实在窗口内经历了这次故障。
### Decision 12getIncidents24h 作为独立方法
**选择**`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 窗口内的事件计数,该误差可接受且难以避免(需要额外查询窗口外数据才能消除)

View File

@@ -0,0 +1,33 @@
## Why
当前前端统计指标存在三个层面的问题1计算逻辑缺陷——可用率无时间窗口导致历史数据稀释近期变化、`computeTrendStats` 从百分比反推整数有精度损失、`lastCheckTime` 返回但未展示2指标维度单一——Summary 只有计数、Drawer 统计区 4 个指标本质是同一维度的重复表达、表格缺少连续状态等关键运维信息3缺少性能和可靠性指标——无 P95 延迟、无 MTTR、无故障分析。
## What Changes
- **计算逻辑修复**:可用率查询增加时间窗口参数(默认 24hTrend 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 多维度布局,趋势图改为延迟范围面积图+异常标记点,删除 computeTrendStatsStatusDonut 数据来源改为 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` 的响应结构

View File

@@ -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 为 nullrecentSamples 为空数组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` 字段(语义变更为基于时间窗口计算)

View File

@@ -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.Texttheme="secondary"
#### Scenario: 数据新鲜度警告
- **WHEN** lastCheckTime 距当前时间超过 60 秒
- **THEN** 相对时间文本 SHALL 使用警告色(--td-warning-color
#### Scenario: 统计数据自动刷新
- **WHEN** 页面处于打开状态
- **THEN** 统计卡片 SHALL 通过 TanStack Query 的 refetchInterval=8000 自动刷新数据

View File

@@ -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 或 DOWNmatched=true 为 UPmatched=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 返回空数组

View File

@@ -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 Divideralign="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 来自 statsDataTargetStatsResponse第二行数据同样来自 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 RadioGroupvariant=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 DateRangePickermode=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 变化自动重新请求

View File

@@ -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 包含 p95DurationMsnumber | null、p99DurationMsnumber | null、avgDurationMsnumber | null、mttrnumber | null、longestOutagenumber | null、incidentCountnumber、currentStreak{ up: boolean; count: number }、totalChecksnumber、upChecksnumber、downChecksnumber、availabilitynumber

View File

@@ -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 展示 "-"

View File

@@ -0,0 +1,55 @@
## 1. 共享类型与数据层
- [ ] 1.1 扩展 `src/shared/api.ts`SummaryResponse 增加 incidents24hTrendPoint 增加 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 hookqueryKey: ["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 Tagtheme=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 和格式检查,修复所有问题

View File

@@ -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 → Tabprops 类型明确,不会造成 prop drilling 问题 |
## Open Questions
无。方案已在 explore 阶段与用户确认。

View File

@@ -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 中前端目录结构和组件清单

View File

@@ -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 不被包含在产物中

View File

@@ -1,91 +0,0 @@
## MODIFIED Requirements
### Requirement: 目标详情 Drawer
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。
#### Scenario: 打开 Drawer
- **WHEN** 用户点击某个目标表格行
- **THEN** 系统 SHALL 从右侧滑出 Drawerplacement="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 状态

View File

@@ -1,69 +0,0 @@
## MODIFIED Requirements
### Requirement: 表格列定义
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
#### Scenario: 状态列
- **WHEN** 表格渲染
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60pxfixed="left"居中对齐支持筛选UP/DOWN/全部。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style
#### Scenario: 名称列
- **WHEN** 表格渲染
- **THEN** 名称列 SHALL 显示目标名称支持字母排序zh-CNellipsis 超长名称自动省略并 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 导入,不在组件中重复定义

View File

@@ -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**: 无需迁移,删除即可。

View File

@@ -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 说明

View File

@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-13

View File

@@ -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 一份,缓存反而减少了峰值内存。

View File

@@ -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` — 补充截断相关测试

View File

@@ -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 至少包含一个已知 operatorbody 提取规则可以不配置 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 风险)

View File

@@ -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 rulescontains + json + css集成测试
## 7. 质量保障
- [ ] 7.1 执行完整测试套件 `bun test`、代码检查 `bun run lint`、格式检查 `bun run format:check` 确保无回归
- [ ] 7.2 更新 DEVELOPMENT.md 中 ReDoS 校验相关说明(如有必要)

View File

@@ -10,7 +10,7 @@ context: |
- 这是基于bun实现的前端后一体化项目使用bun作为唯一包管理器严禁使用pnpm、npm使用bunx运行工具严禁使用npx、pnpx
- src/server目录下是基于bun实现的后端代码
- 后端库使用优先级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类 > 自行开发组件
- 前端严禁组件内联style属性、CSS覆盖TD内部类名、使用!important、硬编码色值
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明

View File

@@ -1,12 +1,12 @@
## Purpose
定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、参数校验逻辑抽取为中间件、静态资源服务独立维护
定义后端 API 路由的组织规范:按端点拆分为独立 handler、共享响应工具集中管理、路径参数由 Bun routes 解析,静态资源服务由 HTML import manifest 管理
## Requirements
### Requirement: 路由按职责拆分
系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出一个 handler 函数供 app.ts 统一注册。
系统 SHALL 将 HTTP 路由处理逻辑按 API 端点拆分为独立模块,每个模块导出 route handler 函数供 routes 对象统一注册。
#### Scenario: health 端点独立路由
- **WHEN** 客户端请求 `GET /health`
@@ -22,11 +22,11 @@
#### Scenario: history 端点独立路由
- **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 端点独立路由
- **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: 共享辅助函数集中管理
系统 SHALL 将跨路由共享的响应格式化函数抽取到 helpers.ts 模块,单一职责、集中管理。
@@ -43,36 +43,17 @@
- **WHEN** 需要将 StoredCheckResult 映射为 API CheckResult
- **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: 静态资源服务独立管理
系统 SHALL 将静态资源服务、SPA fallback 和 Content-Type 映射逻辑抽取到 static.ts 模块
系统 SHALL 将 SPA fallback 逻辑交给 routes 对象中的 HTML import 通配符处理,静态资源服务由 Bun 内置 manifest 机制自动处理
#### Scenario: 根路径返回 index.html
- **WHEN** 客户端请求 `/`
- **THEN** `static.ts` 的 handler 返回 index.html设置正确的 Content-Type 和 Cache-Control
- **THEN** routes 中注册的 HTML import 自动返回 index.html
#### Scenario: 资源文件返回正确 Content-Type
- **WHEN** 客户端请求 `/assets/main.js`
- **THEN** `static.ts` 的 handler 根据文件扩展名返回正确的 Content-Type`.js``text/javascript`
- **WHEN** 客户端请求构建后的静态资源
- **THEN** Bun 内置 manifest 机制自动返回正确的 Content-Type 和缓存头
#### Scenario: SPA fallback
- **WHEN** 客户端请求非 API、非资源的路径`/dashboard`
- **THEN** `static.ts` 的 handler 返回 index.html 实现 SPA 的客户端路由
- **THEN** routes 中注册的 `"/*"` HTML import 通配符返回 index.html 实现 SPA 的客户端路由

View File

@@ -17,7 +17,7 @@
- **THEN** 该 target_id 在返回的 Map 中 SHALL 不存在对应的 key
### Requirement: 批量查询目标统计
系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计totalChecks 和 availability
系统 SHALL 提供 `getAllTargetStats` 方法,通过单次 SQL GROUP BY 聚合查询获取所有 target 的拨测统计totalChecks 和 availabilityavailability 计算精度 SHALL 与 `getTargetStats` 一致,统一使用 `Math.round(value * 100) / 100` 保留两位小数。
#### Scenario: 获取所有目标的聚合统计
- **WHEN** 调用 `getAllTargetStats()`
@@ -29,7 +29,7 @@
#### Scenario: availability 精度
- **WHEN** 计算 availabilityupCount / totalChecks * 100
- **THEN** 结果 SHALL 四舍五入保留两位小数
- **THEN** 结果 SHALL 使用 `Math.round(value * 100) / 100` 四舍五入保留两位小数,与 `getTargetStats` 方法一致
### Requirement: summary 查询使用批量方法
`getSummary` 方法 SHALL 使用 `getLatestChecksMap` 一次性获取所有 target 的最新检查结果,而非对每个 target 逐条查询。
@@ -39,11 +39,30 @@
- **THEN** 系统 SHALL 调用 `getLatestChecksMap()` 一次获取所有最新结果,在内存中遍历统计 up/down 数量,而非循环 N 次调用 `getLatestCheck()`
### 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: 目标列表使用批量查询
- **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() 缓存
ProbeStore 中不涉及事务内复用的单次读/写操作 SHALL 使用 `this.db.query()` 而非 `this.db.prepare()`,利用 bun:sqlite 内置的 statement 缓存机制。

View 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 注入)

View File

@@ -50,7 +50,7 @@
- **THEN** checker SHALL 返回 `ConfigValidationIssue`,而不是直接抛出最终用户错误字符串
### 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 接口包含必要方法
- **WHEN** 开发者实现一个新的 Checker
@@ -68,20 +68,24 @@
- **WHEN** checker 定义 `type: "tcp"`
- **THEN** checker 的 `configKey` SHALL 默认使用 `"tcp"`,对应 target 的 `tcp` 分组和 defaults.tcp 分组
#### Scenario: 接口方法使用 base 类型
- **WHEN** 开发者查看 `CheckerDefinition` 接口签名
- **THEN** `resolve`参数 SHALL 为 `RawTargetConfig`返回值 SHALL 为 `ResolvedTargetBase``execute` 的参数 SHALL 为 `ResolvedTargetBase``serialize` 的参数 SHALL 为 `ResolvedTargetBase`
#### Scenario: 接口方法使用泛型约束
- **WHEN** 开发者查看 `CheckerDefinition<TResolved>` 接口签名
- **THEN** `resolve` 的返回值 SHALL 为 `TResolved``execute` 的参数 SHALL 为 `TResolved``serialize` 的参数 SHALL 为 `TResolved`
#### Scenario: checker 实现内部 narrow
- **WHEN** HttpChecker 的 execute 方法接收 `ResolvedTargetBase` 参数
- **THEN** 方法内部 SHALL 将参数 narrow 为 `ResolvedHttpTarget`(通过 type assertion然后使用具体类型的字段
#### 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 抛出错误。
系统 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 实例
- **THEN** 返回的 SHALL 是之前注册的 HttpChecker 实例(类型为 `CheckerDefinition`
#### Scenario: 获取未注册的 type
- **WHEN** 调用 `registry.get("unknown")` 且未注册对应 type 的 checker

View File

@@ -1,6 +1,6 @@
## Purpose
定义项目代码质量门禁、格式化检查、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产 executable 行为
定义项目代码质量门禁、格式化检查、快速检查和完整验证命令的行为要求,确保开发者可以通过文档化命令稳定验证源码质量、基础测试和生产构建
## Requirements
@@ -108,11 +108,11 @@
- **THEN** `check` MUST 以非零状态退出且不静默忽略失败
### Requirement: 完整验证命令
项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产 executable 行为
项目 SHALL 提供完整 `verify` 命令,用于提交前或发布前验证当前源码、测试和生产构建。原 executable smoke test 暂时移除,后续通过独立变更重新设计
#### Scenario: 运行完整验证
- **WHEN** 开发者运行 `bun run verify`
- **THEN** 系统 SHALL 先运行 `check`,再运行生产构建和 executable smoke test
- **THEN** 系统 SHALL 先运行 `check`,再运行生产构建
#### Scenario: 完整验证失败
- **WHEN** `verify` 中任一阶段失败

View File

@@ -117,15 +117,27 @@
- **THEN** 系统 SHALL 判定 matched 为 false
### Requirement: 结构化 expect 失败信息
系统 SHALL 在任一 expect 规则失败时生成结构化 failure用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。
系统 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** 失败规则的实际值超过系统允许记录的摘要长度
- **THEN** 系统 MUST 截断 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: 状态码范围匹配
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"``"2xx"``"3xx"``"4xx"``"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。
@@ -163,10 +175,10 @@
- **THEN** 系统 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 至少包含一个已知 operatorbody 提取规则可以不配置 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 至少包含一个已知 operatorbody 提取规则可以不配置 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 可编译
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
#### Scenario: body rule 不支持 match 字段
@@ -225,6 +237,18 @@
- **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 风险)
### Requirement: HTTP body 运行期失败结构化
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。

View File

@@ -1,45 +1,41 @@
## Purpose
定义 Vite + React + TypeScript 前端开发工作流、开发期 API 代理和共享契约的行为要求。
定义 Bun.serve fullstack + React + TypeScript 前端开发工作流、开发期 API 访问和共享契约的行为要求。
## Requirements
### Requirement: Vite React 开发服务器
系统 SHALL 提供基于 Vite + React + TypeScript 的前端开发工作流,并支持热模块替换
系统 SHALL 提供基于 Bun.serve fullstack 模式的前端开发工作流,并支持热模块替换和 React Fast Refresh
#### Scenario: 启动前端开发服务器
- **WHEN** 开发者启动前端开发命令
- **THEN** 前端 SHALL 由 Vite 提供服务,并启用 React 热模块替换
- **WHEN** 开发者启动开发命令
- **THEN** 前端 SHALL 由 Bun.serve 的 HTML import 机制提供服务,并通过 `development: { hmr: true, console: true }` 启用 HMR、React Fast Refresh 和浏览器 console 回显
#### Scenario: 构建前端静态资源
- **WHEN** 开发者运行前端生产构建命令
- **THEN** 系统 SHALL 产出可由 Bun 后端服务的前端静态资源
- **THEN** 系统 SHALL 通过 Bun.build 的 HTML import ahead-of-time bundling 产出可由 Bun 后端服务的前端静态资源
### Requirement: 前端开发期 API 代理
前端开发服务器 SHALL 在本地开发期间`/api/*` 请求代理到 Bun 后端服务
前端开发服务器 SHALL 在本地开发期间无需代理配置即可访问后端 API因为前后端运行在同一进程同一端口
#### Scenario: 前端开发期调用拨测 API
- **WHEN** 浏览器从 Vite 开发源请求 `/api/summary``/api/targets` 等拨测 API
- **THEN** Vite SHALL 将请求转发到 Bun 后端服务,且不需要浏览器 CORS 配置
- **WHEN** 浏览器从开发服务器请求 `/api/summary``/api/targets` 等拨测 API
- **THEN** Bun.serve SHALL 直接由 routes 中注册的 API handler 处理请求,无需 proxy 转发
#### Scenario: 开发期访问非 API 前端路由
- **WHEN** 浏览器从 Vite 开发源请求非 API 前端路由
- **THEN** Vite SHALL 将该请求作为前端应用流量处理,而不是转发到后端
- **WHEN** 浏览器从开发服务器请求非 API 前端路由
- **THEN** Bun.serve SHALL 将该请求作为前端应用流量处理SPA fallback 返回 HTML
### Requirement: 开发期后端端口一致性
项目 SHALL 保证文档化的全栈开发命令中Vite proxy 目标端口与 Bun 后端监听端口来自同一配置来源
### Requirement: 开发期单端口运行
项目 SHALL 保证开发命令中前端页面、HMR 和后端 API 由同一个 Bun.serve 进程在同一端口提供服务
#### Scenario: 使用默认开发端口
- **WHEN** 开发者未提供端口覆盖并运行文档化的全栈开发命令
- **THEN** Bun 后端 SHALL 监听默认端口,且 Vite SHALL 将 `/api/*` 代理到同一端口
- **WHEN** 开发者未提供端口覆盖并运行开发命令
- **THEN** Bun.serve SHALL 默认端口同时提供前端页面、HMR 和后端 API
#### Scenario: 使用 PORT 覆盖开发端口
- **WHEN** 开发者通过 `PORT` 覆盖后端端口并运行文档化的全栈开发命令
- **THEN** Bun 后端 SHALL 监听该端口,且 Vite SHALL 将 `/api/*` 代理到同一端口
#### Scenario: 避免代理端口与后端端口分叉
- **WHEN** 开发期脚本需要向 Vite 传递后端端口
- **THEN** 该代理端口 MUST 从文档化的后端端口配置派生,而不是作为独立对外配置导致分叉
#### Scenario: 使用配置覆盖开发端口
- **WHEN** 开发者通过配置文件覆盖端口并运行开发命令
- **THEN** Bun.serve SHALL 在配置端口同时提供前端页面、HMR 和后端 API
### Requirement: 前端使用相对 API 路径
除非有文档化的部署配置覆盖该行为,前端代码 MUST 通过相对 `/api/*` URL 调用后端 API。
@@ -57,7 +53,7 @@
#### Scenario: 启动全栈开发
- **WHEN** 开发者运行文档化的全栈开发命令
- **THEN** 系统 SHALL 启动 Vite 前端开发服务器和 Bun 后端服务
- **THEN** 系统 SHALL 启动单个 Bun.serve 进程,同时提供前端 HMR 和后端 API 服务
### Requirement: 开发质量命令文档化
项目 SHALL 在前端开发工作流文档中说明日常检查和完整验证命令。

View File

@@ -5,11 +5,11 @@
## Requirements
### Requirement: Bun HTTP 运行时
系统 SHALL 运行一个 Bun HTTP server由单个进程提供后端 API、健康检查、生产静态资源和 SPA fallback 行为
系统 SHALL 运行一个 Bun HTTP server使用 `routes` 对象声明式注册 HTML 页面路由和 API 端点,由单个进程提供后端 API、健康检查和前端服务
#### Scenario: 启动运行时服务器
- **WHEN** server 进程成功启动
- **THEN** 它 SHALL 监听 YAML 配置文件中指定的 host 和 port并记录实际 server URL
- **THEN** 它 SHALL 监听配置文件中指定的 host 和 port通过 routes 对象注册所有路由,并记录实际 server URL
#### Scenario: 通过 YAML 配置提供运行时参数
- **WHEN** 通过 YAML 配置文件提供 host、port、数据目录等参数
@@ -21,22 +21,18 @@
#### Scenario: 提供拨测相关 API
- **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 语义
系统 SHALL 为运行时端点提供明确的 HTTP method 语义,避免不支持的 method 被错误地当作成功请求处理
系统 SHALL 为运行时端点声明实际支持的 GET handler不支持的 API method SHALL 按未匹配 API 路由处理,不再保留自定义 405 和 Allow header 语义
#### Scenario: GET 请求访问运行时端点
- **WHEN** 客户端使用 `GET` 请求 `/health``/api/*` 端点
- **THEN** Bun server SHALL 返回对应端点的成功响应
#### Scenario: HEAD 请求访问运行时端点
- **WHEN** 客户端使用 `HEAD` 请求 `/health` `/api/*` 端点
- **THEN** Bun server SHALL 返回 `GET` 相同的成功状态和 headers但 MUST NOT 返回响应
#### Scenario: 不支持的 method 访问运行时端点
- **WHEN** 客户端使用不支持的 method 请求 `/health``/api/*` 端点
- **THEN** Bun server SHALL 返回 405 状态码和 Allow header
#### Scenario: 不支持的 API method 请求
- **WHEN** 客户端使用不支持的 method 请求已存在的 `/api/*` 端点
- **THEN** `/api/*` 通配符 SHALL 返回包含 `error``status` 字段的 JSON 404 响应
### Requirement: API 路由命名空间
系统 MUST 将 `/api/*` 保留给后端 API 路由。
@@ -50,15 +46,15 @@
- **THEN** Bun server MUST 返回 JSON 404 响应,而不是前端 HTML 文档
### Requirement: API 错误响应一致性
系统 SHALL 为 API 命名空间内的错误返回机器可读 JSON 响应。
系统 SHALL 为 API 命名空间内的未匹配路由和未匹配 method 返回机器可读 JSON 404 响应。
#### Scenario: 未知 API 路由
- **WHEN** 客户端请求未知的 `/api/*` 路由
- **THEN** Bun server MUST 返回包含 `error``status` 字段的 JSON 404 响应,而不是前端 HTML 文档
#### Scenario: API method 不允许
#### Scenario: API method 不匹配
- **WHEN** 客户端使用不支持的 method 请求已存在的 API 路由
- **THEN** Bun server MUST 返回包含 `error``status` 字段的 JSON 405 响应
- **THEN** Bun server MUST 返回包含 `error``status` 字段的 JSON 404 响应
### Requirement: 健康检查端点
系统 SHALL 在前端 SPA fallback 之外暴露健康检查端点。
@@ -68,56 +64,48 @@
- **THEN** Bun server SHALL 返回成功的、机器可读的健康检查响应
### Requirement: 生产静态资源服务
系统 SHALL 在生产模式下 Bun runtime 服务 Vite 生产资源。
系统 SHALL 在生产模式下通过 Bun 内置的 HTML import manifest 机制服务前端资源。
#### Scenario: 请求构建后的资源
- **WHEN** 客户端请求构建后的前端资源,例如 `/assets/app.js`
- **THEN** Bun server SHALL 返回该资源并带有适当的 content type
- **WHEN** 客户端请求构建后的前端资源
- **THEN** Bun server SHALL 通过 manifest 自动返回该资源并带有适当的 content type 和 content-addressable hash URL
#### Scenario: 请求前端根路径
- **WHEN** 客户端请求 `/`
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
- **THEN** Bun server SHALL 通过 routes 中注册的 HTML import 返回前端入口 HTML 文档
### Requirement: 生产缓存策略
系统 SHALL 为生产静态资源和前端入口 HTML 使用明确的缓存策略。
系统 SHALL 利用 Bun 内置的缓存机制为生产静态资源提供缓存策略。
#### Scenario: 请求前端入口 HTML
- **WHEN** 生产 Bun server 返回前端入口 HTML 文档
- **THEN** 响应 SHALL 使用 `Cache-Control: no-cache`
- **THEN** 响应 SHALL 包含 Bun 自动生成的 ETag header
#### Scenario: 请求构建后的静态资源
- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源
- **THEN** 响应 SHALL 使用长缓存策略 `public, max-age=31536000, immutable`
#### Scenario: 请求未知静态资源
- **WHEN** 客户端请求不存在的 `/assets/*` 资源或带文件扩展名的未知路径
- **THEN** Bun server MUST 返回 404且 MUST NOT 返回前端入口 HTML 文档
- **WHEN** 生产 Bun server 返回构建后的静态资源
- **THEN** 响应 SHALL 包含 Bun 自动生成的 ETag header 和 content-addressable hash URL
### Requirement: 低风险安全响应头
系统 SHALL 在生产运行时响应中附加低风险安全响应头,提升基础安全性且不提前约束未来前端资源策略
#### Scenario: 生产 HTML 响应包含安全头
- **WHEN** 生产 Bun server 返回前端 HTML 文档
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff``Referrer-Policy` headers
系统 SHALL 在生产运行时的 JSON API 响应中附加低风险安全响应头HTML 和静态资源响应由 Bun HTML import manifest 返回其内置 headers
#### Scenario: 生产 JSON 响应包含安全头
- **WHEN** 生产 Bun server 返回 `/health``/api/*` JSON 响应
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff``Referrer-Policy` headers
#### Scenario: 生产静态资源响应包含安全头
- **WHEN** 生产 Bun server 返回 Vite 构建后的静态资源
- **THEN** 响应 SHALL 包含 `X-Content-Type-Options: nosniff``Referrer-Policy` headers
#### Scenario: 生产 HTML 和静态资源响应使用 Bun 内置 headers
- **WHEN** 生产 Bun server 返回前端 HTML 文档或构建后的静态资源
- **THEN** 响应 SHALL 使用 Bun HTML import manifest 提供的内置 headers不要求附加自定义安全 headers
### Requirement: SPA fallback 行为
系统 SHALL 在生产环境中为非 API、非静态资源的前端路由返回前端入口 HTML 文档。
系统 SHALL 通过 routes 中注册的 `"/*"` HTML import 通配符为非 API 路径返回前端入口 HTML 文档。
#### Scenario: 刷新前端路由
- **WHEN** 客户端请求前端路由,例如 `/dashboard`
- **THEN** Bun server SHALL 返回前端入口 HTML 文档
- **THEN** routes 中的 `"/*"` 通配符 SHALL 返回前端入口 HTML 文档
#### Scenario: 保留 API 错误语义
- **WHEN** 客户端请求未知的 `/api/*` 路由
- **THEN** Bun server MUST NOT 返回前端入口 HTML 文档
- **THEN** `/api/*` 通配符 MUST 返回 JSON 404 响应,而不是前端入口 HTML 文档
### Requirement: 优雅关机
系统 SHALL 在收到终止信号时正确清理资源。

View File

@@ -1,7 +1,11 @@
## ADDED Requirements
## Purpose
定义系统运行时元数据 APIchecker 类型列表等元信息的对外暴露方式。
## Requirements
### Requirement: Meta 信息 API
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。未匹配 method SHALL 按 API 通配符处理为 JSON 404不再保留自定义 HEAD/405 语义。
#### Scenario: 获取 checker 类型列表
- **WHEN** 客户端请求 `GET /api/meta`
@@ -11,13 +15,9 @@
- **WHEN** 系统启动并注册了 checker
- **THEN** `/api/meta` 返回的 `checkerTypes` SHALL 与 `CheckerRegistry.supportedTypes` 完全一致
#### Scenario: 仅允许 GET/HEAD 方法
- **WHEN** 客户端使用 POST/PUT/DELETE 等方法请求 `/api/meta`
- **THEN** 系统 SHALL 返回 405 状态码
#### Scenario: HEAD 请求返回空体
- **WHEN** 客户端使用 HEAD 方法请求 `/api/meta`
- **THEN** 系统 SHALL 返回 200 状态码和正确的 Content-Type headerbody 为空
#### Scenario: 不支持的 method 请求
- **WHEN** 客户端使用 POST/PUT/DELETE/HEAD 等未声明 method 请求 `/api/meta`
- **THEN** `/api/*` 通配符 SHALL 返回 JSON 404 响应
### Requirement: MetaResponse 共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。

View File

@@ -90,7 +90,7 @@
- **THEN** 系统 SHALL 返回与之前格式一致的健康检查响应
### Requirement: API 错误处理
系统 SHALL 对不存在的目标 ID 和无效参数返回适当的 HTTP 错误响应。
系统 SHALL 对不存在的目标 ID、无效参数和超出范围的分页参数返回适当的 HTTP 错误响应。
#### Scenario: 查询不存在的目标
- **WHEN** 客户端请求 `GET /api/targets/999/history`
@@ -104,6 +104,14 @@
- **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 状态码和错误信息
@@ -122,3 +130,25 @@
#### Scenario: 无失败信息
- **WHEN** 检查结果 matched=true
- **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[]` 字段

View File

@@ -291,3 +291,18 @@
#### Scenario: retention 字段缺省
- **WHEN** 配置文件中未指定 `runtime.retention`
- **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 解析为绝对路径

View File

@@ -16,16 +16,24 @@
- **THEN** 系统 SHALL 创建两个独立定时器,分别按各自频率调度
### Requirement: 组内并发拨测
系统 SHALL 在每次调度 tick 时并发执行同组内目标的检查,但实际同时运行的检查数 MUST 受全局 `runtime.maxConcurrentChecks` 限制。
系统 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** 同组中某个目标的检查请求超时或失败
- **WHEN** 同组中某个目标的检查请求超时或失败checker 正常返回 CheckResult
- **THEN** 其他目标的检查 SHALL 正常完成并记录结果
#### Scenario: 同组中某个目标的 checker 执行 rejected
- **WHEN** 同组中某个目标的 checker 执行抛出未捕获异常Promise rejected
- **THEN** 系统 SHALL 为该目标写入一条 `matched: false` 的 check_resultfailure 为 `{ 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 个检查,其余检查等待并发槽位释放

View File

@@ -1,15 +1,19 @@
## ADDED Requirements
## Purpose
TBD - 统一服务启动引导函数,封装开发和生产模式的完整启动序列。
## Requirements
### 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: 开发模式启动
- **WHEN** `dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`
- **THEN** 系统 SHALL 完成完整启动序列,不传入 staticAssets
#### Scenario: 生产模式启动
- **WHEN** build entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })`
- **THEN** 系统 SHALL 完成完整启动序列,并将 staticAssets 传递给 startServer
- **WHEN** `main.ts` 调用 `bootstrap({ configPath, mode: "production" })`
- **THEN** 系统 SHALL 完成完整启动序列,并`server.ts` 中的 HTML import 路由接管前端资源
#### Scenario: 启动失败处理
- **WHEN** 启动过程中任何步骤抛出异常
@@ -20,19 +24,19 @@
- **THEN** bootstrap 注册的 shutdown handler SHALL 调用 engine.stop() 和 store.close() 后退出
### Requirement: BootstrapOptions 接口
`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,包含 `configPath: string``mode: RuntimeMode``staticAssets?: StaticAssets`
`bootstrap` 函数 SHALL 接受 `BootstrapOptions` 参数,包含 `configPath: string``mode: RuntimeMode`
#### Scenario: 最小配置
- **WHEN** 仅传入 configPath 和 mode
- **THEN** 系统 SHALL 正常启动staticAssets 为 undefined
- **THEN** 系统 SHALL 正常启动
### Requirement: dev.ts 和 build entry 使用 bootstrap
`dev.ts``scripts/build.ts` 生成的 server entry SHALL 调用 `bootstrap()` 而非各自维护启动序列。
### Requirement: dev.ts 和生产入口使用 bootstrap
`dev.ts``src/server/main.ts` SHALL 调用 `bootstrap()` 而非各自维护启动序列。
#### Scenario: dev.ts 调用 bootstrap
- **WHEN** 开发者运行 `bun run dev:server`
- **WHEN** 开发者运行 `bun run dev`
- **THEN** `dev.ts` SHALL 调用 `bootstrap` 完成启动
#### Scenario: build entry 调用 bootstrap
#### Scenario: main.ts 调用 bootstrap
- **WHEN** 生产可执行文件启动
- **THEN** 生成的 entry SHALL 调用 `bootstrap` 完成启动
- **THEN** `main.ts` SHALL 调用 `bootstrap` 完成启动

View File

@@ -1,61 +1,42 @@
## Purpose
定义将 Vite 前端资源与 Bun 后端打包为单个 standalone executable 的生产构建、运行配置和验证要求。
定义将 Bun HTML import 前端资源与 Bun 后端打包为单个 standalone executable 的生产构建、运行配置和验证要求。
## Requirements
### Requirement: 生产构建顺序
生产构建 MUST 在编译 Bun 后端 executable 之前先构建 Vite 前端
生产构建 MUST 通过 Bun.build 的 HTML import 识别机制一步完成前端资源打包和后端编译
#### Scenario: 运行生产构建
- **WHEN** 开发者运行生产构建命令
- **THEN** 系统 MUST 调用 Bun standalone executable 编译之前生成前端静态资源
- **THEN** 系统 MUST 调用 Bun.build自动识别 server 入口中的 HTML import 并完成前端 bundling 和后端编译
#### Scenario: 前端构建失败
- **WHEN** 前端生产构建失败
#### Scenario: 前端 bundling 失败
- **WHEN** Bun.build 在处理 HTML import 中的前端资源时失败
- **THEN** 系统 MUST 停止生产构建,且不能输出 stale executable
### Requirement: 构建生成确定性
生产构建 SHALL 以稳定顺序生成嵌入静态资源清单,减少重复构建产生无意义差异。
#### Scenario: 生成静态资源清单
- **WHEN** 生产构建扫描 Vite 输出目录并生成嵌入资源模块
- **THEN** 资源条目 SHALL 按稳定顺序输出
#### Scenario: 重复构建相同前端产物
- **WHEN** Vite 输出内容未变化且生产构建重复运行
- **THEN** 生成的嵌入资源模块 SHALL 保持语义一致且不依赖文件系统遍历顺序
### 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
- **WHEN** 生成的 executable 在兼容目标平台上运行
- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun、Vite`node_modules`
- **THEN** 它 SHALL 启动全栈应用,且不要求目标机器安装 Node.js、Bun 或 `node_modules`
#### Scenario: 服务嵌入的前端
- **WHEN** executable 收到前端根路径请求
- **THEN** 它 SHALL 从 executable 内包含的资源服务前端,且不需要外部 `dist/` 目录
- **THEN** 它 SHALL 通过 Bun 内置的 HTML import manifest 机制服务前端资源,且不需要外部 `dist/` 目录
#### Scenario: 服务嵌入 API 和页面
- **WHEN** 生成的 executable 启动,且浏览器打开前端根路径
- **THEN** 页面 SHALL 展示同一个 executable 进程中 `/api/summary``/api/targets` 返回的数据
#### Scenario: 构建成功后清理中间产物
#### Scenario: 构建成功不生成自定义中间产物
- **WHEN** 生产构建成功完成并输出 executable
- **THEN** 系统 SHALL 自动删除 `.build/` 目录及其所有内容
- **THEN** 系统 SHALL 不生成 `.build/` 静态资源清单或 server entry 中间产物
#### Scenario: 构建失败时保留中间产物
- **WHEN** 生产构建在任意步骤失败前端构建、中间产物生成、Bun 编译)
- **THEN** `.build/` 目录 SHALL 保留在磁盘上以供排查
#### Scenario: checker 注册通过 import 链触发
- **WHEN** 生成的入口代码 import config-loader 模块
- **THEN** checkerRegistry 单例 SHALL 通过模块依赖链自动完成注册,入口代码 SHALL NOT 显式调用任何注册函数
#### Scenario: 生产入口优雅关闭
- **WHEN** executable 进程收到 SIGINT 或 SIGTERM 信号
- **THEN** 系统 SHALL 调用 engine.stop() 停止所有定时器,调用 store.close() 关闭数据库连接,然后以退出码 0 退出进程
#### Scenario: 构建失败时保留 stale executable
- **WHEN** 生产构建在任意步骤失败
- **THEN** 系统 SHALL 不输出上一次构建遗留的 stale executable
### Requirement: 外部运行时配置
executable MUST 将环境相关运行时配置保留在嵌入的前端和 server bundle 之外。
@@ -69,20 +50,12 @@ executable MUST 将环境相关运行时配置保留在嵌入的前端和 server
- **THEN** executable SHALL 使用文档化的默认值
### Requirement: 构建验证
项目 SHALL 提供验证,证明生产 executable 可以服务 API、健康检查、静态资源和 SPA fallback 路由,并且完整验证 MUST 针对当前源码重新构建后的 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
项目 SHALL 提供 `verify` 命令执行质量检查和生产构建;原 smoke test 暂时移除executable 路由验证由后续变更重新设计
#### Scenario: 完整验证重新构建 executable
- **WHEN** 开发者运行完整验证命令
- **THEN** 系统 MUST 先基于当前源码执行生产构建,再对新生成的 executable 运行 smoke test
- **THEN** 系统 MUST 先执行质量检查,再基于当前源码执行生产构建
#### Scenario: 验证失败
- **WHEN** 任一代表性生产路由、响应头、生产模式或构建阶段检查失败
- **THEN** 验证 SHALL 使构建或测试命令失败
- **WHEN** 质量检查或构建阶段失败
- **THEN** 验证 SHALL 使命令失败

View File

@@ -5,7 +5,7 @@
## Requirements
### Requirement: TanStack Query 数据层
前端 SHALL 使用 TanStack Query@tanstack/react-query管理所有 API 请求,替代手写 fetch hooks
前端 SHALL 使用 TanStack Query@tanstack/react-query管理所有 API 请求,数据层代码 SHALL 按职责拆分为独立 hook 文件
#### Scenario: QueryClient 配置
- **WHEN** 应用启动
@@ -34,6 +34,40 @@
- **WHEN** 查询某目标的历史记录
- **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 轮询查询
系统 SHALL 使用 useQuery 实现总览统计的自动轮询。

View File

@@ -5,7 +5,7 @@
## Requirements
### Requirement: 目标详情 Drawer
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。
#### Scenario: 打开 Drawer
- **WHEN** 用户点击某个目标表格行
@@ -13,7 +13,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
#### Scenario: 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
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
@@ -35,6 +35,65 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 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 状态
### Requirement: 时间范围选择器
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。时间选择器 SHALL 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。

View File

@@ -32,7 +32,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件direction=vertical, size=32px统一间距
### Requirement: 表格列定义
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
#### Scenario: 状态列
- **WHEN** 表格渲染
@@ -44,7 +44,11 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
#### Scenario: 类型列
- **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: 可用率列
- **WHEN** 表格渲染
@@ -52,7 +56,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
#### Scenario: 最近状态列
- **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: 延迟列
- **WHEN** 表格渲染
@@ -62,6 +66,32 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
- **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 默认按状态降序排列异常DOWN目标排在最前面。
@@ -103,7 +133,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
- **THEN** 表格 SHALL 设置 size="small"、stripe、hover、bordered
### Requirement: 列定义复用
所有分组的表格 SHALL 共享同一套列定义常量
所有分组的表格 SHALL 共享同一套列定义。
#### Scenario: 列定义提取为常量
- **WHEN** 多个分组表格渲染

View File

@@ -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 值,不抛出异常

View File

@@ -3,18 +3,15 @@
"type": "module",
"private": true,
"scripts": {
"dev": "bun run scripts/dev.ts",
"dev:server": "bun --watch src/server/dev.ts",
"dev:web": "bunx --bun vite --host 127.0.0.1",
"dev": "bun --watch src/server/dev.ts",
"build": "bun run scripts/build.ts",
"lint": "eslint .",
"format": "prettier . --write",
"schema": "bun run scripts/generate-config-schema.ts",
"schema:check": "bun run scripts/generate-config-schema.ts --check",
"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:smoke": "bun run scripts/smoke.ts",
"clean": "bun run scripts/clean.ts",
"typecheck": "tsc --noEmit",
"prepare": "husky"
@@ -27,7 +24,6 @@
"@types/bun": "^1.3.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@@ -40,8 +36,7 @@
"lint-staged": "^17.0.4",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.11"
"typescript-eslint": "^8.59.2"
},
"dependencies": {
"@sinclair/typebox": "^0.34.49",

View File

@@ -1,30 +1,10 @@
import { $ } from "bun";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { dirname, relative, sep } from "node:path";
import { rm } from "node:fs/promises";
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 generatedAssetsPath = fileURLToPath(new URL("../.build/static-assets.ts", import.meta.url));
const generatedEntryPath = fileURLToPath(new URL("../.build/server-entry.ts", import.meta.url));
const entrypoint = fileURLToPath(new URL("../src/server/main.ts", import.meta.url));
await rm(buildDir, { force: true, recursive: 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 result = await Bun.build({
@@ -40,86 +20,14 @@ const result = await Bun.build({
autoloadDotenv: true,
outfile: executablePath,
},
entrypoints: [generatedEntryPath],
entrypoints: [entrypoint],
minify: true,
sourcemap: "linked",
});
if (!result.success) {
await rm(executablePath, { force: true });
throw new Error("Bun executable 构建失败");
console.error("构建失败:", result.logs);
process.exit(1);
}
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);
});
`,
);
}

View File

@@ -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);
}

View File

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

View File

@@ -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 });
}

View File

@@ -1,7 +1,6 @@
import { join } from "node:path";
import type { RuntimeMode } from "../shared/api";
import type { StaticAssets } from "./app";
import type { StartServerOptions } from "./server";
import { loadConfig, type ResolvedConfig } from "./checker/config-loader";
@@ -27,7 +26,6 @@ export interface BootstrapDependencies {
export interface BootstrapOptions {
configPath: string;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
type BootstrapEngine = Pick<ProbeEngine, "start" | "stop">;
@@ -71,7 +69,6 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
serve({
config: { host: config.host, port: config.port },
mode: options.mode,
staticAssets: options.staticAssets,
store,
});
} catch (error) {

View File

@@ -28,7 +28,9 @@ export function mismatchFailure(
export function truncateActual(value: unknown, maxLen = 200): unknown {
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;
return str.slice(0, maxLen) + "...";
return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;
}

View 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;
}

View File

@@ -3,6 +3,7 @@ import type { JsonValue } from "../types";
import { OperatorKeys } from "../schema/fragments";
import { issue, joinPath } from "../schema/issues";
import { isUnsafeRegex } from "./redos";
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)];
try {
new RegExp(value);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
default:
return [issue("unknown-operator", path, "是未知 operator", targetName)];
}

View File

@@ -8,11 +8,20 @@ import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
import { errorFailure, mismatchFailure } from "../../expect/failure";
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true };
let parsedJson: ParsedJsonResult | undefined;
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;
}
@@ -34,36 +43,7 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
}
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 (el.length > 0) {
return {
@@ -75,13 +55,28 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
}
if (el.length === 0) {
const expected = operators.exists === true ? true : "element found";
const actual = operators.exists === true ? false : "no match";
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,
};
}
if (operators.exists === true) return { failure: null, matched: true };
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);
if (!matched) {
return {
@@ -92,21 +87,19 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
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 fullPath = `${rulePath}.json(${path})`;
let json: unknown;
try {
json = JSON.parse(body);
} catch {
const jsonResult = parsedJson ?? parseJsonBody(body);
if (!jsonResult.ok) {
return {
failure: errorFailure("body", fullPath, "body is not valid JSON"),
failure: errorFailure("body", fullPath, jsonResult.error),
matched: false,
};
}
const actual = evaluateJsonPath(json, path);
const actual = evaluateJsonPath(jsonResult.value, path);
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
@@ -129,7 +122,7 @@ function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectRe
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}]`;
if ("contains" in rule) {
@@ -155,7 +148,7 @@ function checkSingleBodyRule(body: string, rule: BodyRule, index: number): Expec
}
if ("json" in rule) {
return checkJsonRule(body, rule.json, rulePath);
return checkJsonRule(body, rule.json, rulePath, parsedJson);
}
if ("css" in rule) {
@@ -208,3 +201,11 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect
}
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 };
}
}

View File

@@ -53,24 +53,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
if (hasBodyRules && expect?.maxDurationMs !== undefined) {
const elapsed = performance.now() - start;
if (elapsed > expect.maxDurationMs) {
const durationMs = Math.round(elapsed);
return makeResult(
t,
timestamp,
elapsed,
mismatchFailure(
"duration",
"duration",
`<=${expect.maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${expect.maxDurationMs}ms`,
),
statusCode,
);
}
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.maxDurationMs) : null;
if (earlyTimeout) {
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
}
if (hasBodyRules) {
@@ -203,6 +188,28 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
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(
data: Uint8Array,
headers: Headers,

View File

@@ -4,6 +4,7 @@ import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { isUnsafeRegex } from "../../expect/redos";
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
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)];
try {
new RegExp(rule);
return [];
} catch {
return [issue("invalid-regex", path, "正则不合法", targetName)];
}
return isUnsafeRegex(rule) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
}
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {

View File

@@ -1,10 +1,6 @@
import type { ApiErrorResponse, CheckFailure, CheckResult, HealthResponse, RuntimeMode } from "../shared/api";
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 {
return { error, status };
}
@@ -36,15 +32,14 @@ export function formatDuration(ms: number): string {
export function jsonResponse(
body: unknown,
options: { headers?: HeadersInit; method?: string; mode: RuntimeMode; status?: number },
options: { headers?: HeadersInit; mode: RuntimeMode; status?: number },
): Response {
const headers = createHeaders(options.mode, {
"Content-Type": "application/json; charset=utf-8",
...options.headers,
});
const responseBody = options.method === "HEAD" ? null : JSON.stringify(body);
return new Response(responseBody, {
return new Response(JSON.stringify(body), {
headers,
status: options.status,
});
@@ -69,11 +64,3 @@ export function mapCheckResult(row: StoredCheckResult): CheckResult {
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
View 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);
});

View File

@@ -1,16 +1,9 @@
import type { RuntimeMode } from "../shared/api";
import { allowsGetHead, createApiError, jsonResponse, methodNotAllowedResponse } from "./helpers";
import { createApiError, jsonResponse } from "./helpers";
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(
pageParam: null | string,
pageSizeParam: null | string,

View File

@@ -1,11 +1,7 @@
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 {
if (!allowsGetHead(method)) {
return methodNotAllowedResponse(["GET", "HEAD"], mode);
}
return jsonResponse(createHealthResponse(), { method, mode });
export function handleHealth(mode: RuntimeMode): Response {
return jsonResponse(createHealthResponse(), { mode });
}

View File

@@ -4,13 +4,13 @@ import type { ProbeStore } from "../checker/store";
import { jsonResponse, mapCheckResult } from "../helpers";
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);
if (idResult instanceof Response) return idResult;
const target = store.getTargetById(idResult.id);
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);
@@ -27,5 +27,5 @@ export function handleHistory(idStr: string, url: URL, method: string, store: Pr
total: result.total,
};
return jsonResponse(response, { method, mode });
return jsonResponse(response, { mode });
}

12
src/server/routes/meta.ts Normal file
View 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 });
}

View File

@@ -3,7 +3,7 @@ import type { ProbeStore } from "../checker/store";
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 response: SummaryResponse = {
down: summary.down,
@@ -12,5 +12,5 @@ export function handleSummary(store: ProbeStore, method: string, mode: RuntimeMo
up: summary.up,
};
return jsonResponse(response, { method, mode });
return jsonResponse(response, { mode });
}

View File

@@ -3,7 +3,7 @@ import type { ProbeStore } from "../checker/store";
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 latestChecksMap = store.getLatestChecksMap();
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 });
}

View File

@@ -4,13 +4,13 @@ import type { ProbeStore } from "../checker/store";
import { jsonResponse } from "../helpers";
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);
if (idResult instanceof Response) return idResult;
const target = store.getTargetById(idResult.id);
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);
@@ -23,5 +23,5 @@ export function handleTrend(idStr: string, url: URL, method: string, store: Prob
totalChecks: row.totalChecks,
}));
return jsonResponse(trend, { method, mode });
return jsonResponse(trend, { mode });
}

View File

@@ -1,27 +1,54 @@
import type { RuntimeMode } from "../shared/api";
import type { StaticAssets } from "./app";
import type { ProbeStore } from "./checker/store";
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 {
config: RuntimeConfig;
mode: RuntimeMode;
staticAssets?: StaticAssets;
store?: ProbeStore;
store: ProbeStore;
}
export function startServer(options: StartServerOptions) {
const { config, mode, staticAssets, store } = options;
const { config, mode, store } = options;
const server = Bun.serve({
fetch: createFetchHandler({
mode,
staticAssets,
store,
}),
development: mode === "development" ? { console: true, hmr: true } : false,
fetch() {
return new Response("Not found", { status: 404 });
},
hostname: config.host,
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}`);

View File

@@ -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);
}

View File

@@ -33,6 +33,10 @@ export interface HistoryResponse {
total: number;
}
export interface MetaResponse {
checkerTypes: string[];
}
export interface RecentSample {
durationMs: null | number;
timestamp: string;

View File

@@ -3,7 +3,8 @@ import { Alert, Loading, Typography } from "tdesign-react";
import { SummaryCards } from "./components/SummaryCards";
import { TargetBoard } from "./components/TargetBoard";
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() {
const { data: summary, error: summaryError, isLoading: summaryLoading } = useSummary();

View 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"
/>
);
}

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

View File

@@ -1,10 +1,11 @@
interface StatusBarProps {
maxSlots?: number;
samples: Array<{ up: boolean }>;
}
export function StatusBar({ samples }: StatusBarProps) {
export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
const blocks = [];
for (let i = 0; i < 30; i++) {
for (let i = 0; i < maxSlots; i++) {
const sample = samples[i];
if (sample) {
blocks.push(

View File

@@ -28,8 +28,8 @@ export function StatusDonut({ down, up }: StatusDonutProps) {
<ResponsiveContainer height={180} width="100%">
<PieChart>
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
{data.map((_, index) => (
<Cell fill={colors[index % colors.length]} key={index} />
{data.map((item, index) => (
<Cell fill={colors[index % colors.length]} key={item.name} />
))}
</Pie>
</PieChart>

View File

@@ -1,15 +1,24 @@
import { useMemo } from "react";
import { Space } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
import { createTargetTableColumns } from "../constants/target-table-columns";
import { useMeta } from "../hooks/use-queries";
import { TargetGroup } from "./TargetGroup";
const EMPTY_CHECKER_TYPES: string[] = [];
interface TargetBoardProps {
onTargetClick: (target: TargetStatus) => void;
targets: TargetStatus[];
}
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[]>();
for (const target of targets) {
const group = target.group;
@@ -29,7 +38,7 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
return (
<Space className="full-width" direction="vertical" size={32}>
{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>
);

View File

@@ -1,30 +1,14 @@
import type { TabValue } from "tdesign-react";
import { useCallback, useState } from "react";
import {
Col,
DateRangePicker,
Descriptions,
Divider,
Drawer,
PrimaryTable,
RadioGroup,
Row,
Skeleton,
Space,
Statistic,
Tabs,
Tag,
Typography,
} from "tdesign-react";
import { DateRangePicker, Drawer, RadioGroup, Space, 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 { StatusDonut } from "./StatusDonut";
import { HistoryTab } from "./HistoryTab";
import { OverviewTab } from "./OverviewTab";
import { StatusDot } from "./StatusDot";
import { TrendChart } from "./TrendChart";
interface TargetDetailDrawerProps {
historyData: HistoryResponse;
@@ -46,43 +30,6 @@ const TIME_SHORTCUTS = [
{ hours: 168, label: "7天", value: "7d" },
] 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({
historyData,
historyLoading,
@@ -123,9 +70,6 @@ export function TargetDetailDrawer({
if (!target) return null;
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 (
<Drawer
@@ -135,7 +79,7 @@ export function TargetDetailDrawer({
<StatusDot up={!!isUp} />
<Typography.Text strong>{target.name}</Typography.Text>
<Tag size="small" theme="primary" variant="light-outline">
{getTargetTypeDisplay(target.type)}
{target.type}
</Tag>
</Space>
}
@@ -163,66 +107,16 @@ export function TargetDetailDrawer({
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
valueType="YYYY-MM-DD HH:mm"
/>
</Space>
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
<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} 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>
<OverviewTab target={target} trendData={trendData} trendLoading={trendLoading} />
</Tabs.TabPanel>
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
<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"
/>
<HistoryTab historyData={historyData} historyLoading={historyLoading} onPageChange={onPageChange} />
</Tabs.TabPanel>
</Tabs>
</Space>
</Drawer>
);
}

View File

@@ -1,17 +1,19 @@
import type { PrimaryTableCol } from "tdesign-react";
import { PrimaryTable } from "tdesign-react";
import type { TargetStatus } from "../../shared/api";
import { TARGET_TABLE_COLUMNS } from "../constants/target-table-columns";
import { GroupHeader } from "./GroupHeader";
interface TargetGroupProps {
columns: Array<PrimaryTableCol<TargetStatus>>;
name: string;
onTargetClick: (target: TargetStatus) => void;
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 down = targets.length - up;
@@ -21,7 +23,7 @@ export function TargetGroup({ name, onTargetClick, targets }: TargetGroupProps)
<PrimaryTable
bordered
className="clickable-table"
columns={TARGET_TABLE_COLUMNS}
columns={columns}
data={targets}
defaultSort={[{ descending: true, sortBy: "latestCheck.matched" }]}
hover

View File

@@ -4,14 +4,9 @@ import type { TrendPoint } from "../../shared/api";
interface TrendChartProps {
data: TrendPoint[];
loading: boolean;
}
export function TrendChart({ data, loading }: TrendChartProps) {
if (loading) {
return <div className="trend-loading">...</div>;
}
export function TrendChart({ data }: TrendChartProps) {
if (data.length === 0) {
return <div className="trend-empty"></div>;
}

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

View File

@@ -7,11 +7,11 @@ import type { TargetStatus } from "../../shared/api";
import { StatusBar } from "../components/StatusBar";
import { StatusDot } from "../components/StatusDot";
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 { getTargetTypeDisplay } from "./target-type-display";
export const TARGET_TABLE_COLUMNS: Array<PrimaryTableCol<TargetStatus>> = [
export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> {
return [
{
align: "center",
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>) => (
<Tag size="small" theme="primary" variant="light-outline">
{getTargetTypeDisplay(row.type)}
{row.type}
</Tag>
),
colKey: "type",
filter: typeFilter,
filter: createTypeFilter(checkerTypes),
title: "类型",
width: 80,
},
@@ -86,7 +86,12 @@ export const TARGET_TABLE_COLUMNS: Array<PrimaryTableCol<TargetStatus>> = [
title: "间隔",
width: 72,
},
];
];
}
export { statusFilter, typeFilter } from "./target-table-filters";
export { availabilitySorter, latencySorter, nameSorter, statusSorter } from "./target-table-sorters";
function createTypeFilter(checkerTypes: string[]): PrimaryTableCol["filter"] {
return {
list: [{ label: "全部", value: "" }, ...checkerTypes.map((type) => ({ label: type, value: type }))],
type: "single",
};
}

View File

@@ -8,12 +8,3 @@ export const statusFilter: PrimaryTableCol["filter"] = {
],
type: "single",
};
export const typeFilter: PrimaryTableCol["filter"] = {
list: [
{ label: "全部", value: "" },
{ label: "HTTP", value: "http" },
{ label: "CMD", value: "command" },
],
type: "single",
};

View File

@@ -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
View File

@@ -0,0 +1 @@
declare module "*.css";

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

View File

@@ -1,26 +1,16 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
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 { 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,
summary: () => ["summary"] as const,
targets: () => ["targets"] 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() {
const queryClient = useQueryClient();
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null);
@@ -29,9 +19,8 @@ export function useTargetDetail() {
const [historyPage, setHistoryPage] = useState(1);
const { data: targetsData } = useTargets();
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({
enabled: selectedTargetId !== null && !!timeFrom && !!timeTo,
@@ -41,7 +30,7 @@ export function useTargetDetail() {
),
queryKey:
selectedTargetId !== null && timeFrom && timeTo
? queryKeys.trend(selectedTargetId, timeFrom, timeTo)
? detailQueryKeys.trend(selectedTargetId, timeFrom, timeTo)
: ["trend", "disabled"],
});
@@ -53,7 +42,7 @@ export function useTargetDetail() {
),
queryKey:
selectedTargetId !== null && timeFrom && timeTo
? queryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
? detailQueryKeys.history(selectedTargetId, timeFrom, timeTo, historyPage)
: ["history", "disabled"],
});
@@ -96,18 +85,3 @@ export function useTargetDetail() {
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