1
0

feat: 新增两个 OpenSpec 变更提案 — 前端架构重构与 HTTP Checker 质量加固

- frontend-architecture-refactor: 拆分 hooks/组件、类型筛选器动态化
- http-checker-quality-hardening: ReDoS 防护、failure 格式修正、测试补全
This commit is contained in:
2026-05-13 18:40:08 +08:00
parent 147a2559ae
commit 76b47006fe
14 changed files with 800 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,145 @@
## 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

@@ -0,0 +1,34 @@
## 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

@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Meta 信息 API
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。
#### Scenario: 获取 checker 类型列表
- **WHEN** 客户端请求 `GET /api/meta`
- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`,包含所有已注册的 checker 类型标识符(如 `["http", "command"]`
#### Scenario: 类型列表来源
- **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 为空
### Requirement: MetaResponse 共享类型
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。
#### Scenario: MetaResponse 类型定义
- **WHEN** 前后端引用 `MetaResponse` 类型
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段

View File

@@ -0,0 +1,109 @@
## 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

@@ -0,0 +1,91 @@
## 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

@@ -0,0 +1,69 @@
## 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

@@ -0,0 +1,13 @@
## 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

@@ -0,0 +1,48 @@
## 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

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

View File

@@ -0,0 +1,93 @@
## 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

@@ -0,0 +1,28 @@
## 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

@@ -0,0 +1,101 @@
## 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

@@ -0,0 +1,38 @@
## 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 校验相关说明(如有必要)