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
This commit is contained in:
@@ -67,15 +67,17 @@ src/
|
||||
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 全局面板查询 hook(summary/targets/meta)
|
||||
use-target-detail.ts 目标详情 Drawer 状态与条件查询 hook
|
||||
utils/ 前端工具函数
|
||||
time.ts 时间处理(subtractHours)
|
||||
stats.ts 趋势统计计算(computeTrendStats)
|
||||
scripts/ 开发、构建、schema 生成和 smoke test 脚本
|
||||
tests/ Bun test 测试(结构镜像 src 目录)
|
||||
openspec/ OpenSpec 变更与规格文档
|
||||
@@ -358,14 +360,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,8 +399,6 @@ 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 导出同步
|
||||
@@ -544,26 +541,29 @@ main.tsx
|
||||
│ │ └── useSummary() ─── GET /api/summary(8s 轮询)
|
||||
│ └── TargetBoard(目标列表)
|
||||
│ ├── useTargets() ─── GET /api/targets(8s 轮询)
|
||||
│ ├── 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(全局面板级查询)
|
||||
├── queryKeys(summary/targets/meta 结构化 query key)
|
||||
├── useSummary() → /api/summary(8s 自动轮询)
|
||||
├── useTargets() → /api/targets(8s 自动轮询)
|
||||
└── useTargetDetail()(组合 hook,管理 Drawer 全部状态)
|
||||
├── 内部复用 useTargets() 的缓存来查找 selectedTarget
|
||||
├── useQuery(/api/targets/:id/trend)(条件查询:enabled 仅当 Drawer 打开且时间范围有效)
|
||||
└── useQuery(/api/targets/:id/history)(条件查询:含分页)
|
||||
└── useMeta() → /api/meta(staleTime: Infinity)
|
||||
|
||||
hooks/use-target-detail.ts(Drawer 状态与详情级条件查询)
|
||||
├── 内部复用 useTargets() 的缓存来查找 selectedTarget
|
||||
├── useQuery(/api/targets/:id/trend)(条件查询:enabled 仅当 Drawer 打开且时间范围有效)
|
||||
└── useQuery(/api/targets/:id/history)(条件查询:含分页)
|
||||
```
|
||||
|
||||
### 2.3 TanStack Query 数据层
|
||||
@@ -574,6 +574,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 +669,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 +685,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/`
|
||||
|
||||
@@ -163,6 +163,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 +173,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`
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-13
|
||||
@@ -1,145 +0,0 @@
|
||||
## Context
|
||||
|
||||
当前前端代码约 970 行,功能完整但存在以下架构问题:
|
||||
|
||||
1. **hook 职责过重**:`hooks/useTargetDetail.ts`(113 行)同时承载全局查询(`useSummary`、`useTargets`)、Drawer 状态管理、条件查询和通用 `fetchJson` 封装,文件名与实际职责不匹配
|
||||
2. **组件体积膨胀**:`TargetDetailDrawer.tsx`(228 行)混合了时间选择逻辑、两个 Tab 的完整渲染、列定义和统计计算
|
||||
3. **类型维护重复**:`target-type-display.ts` 和 `target-table-filters.ts` 各自硬编码 checker 类型列表,新增 checker 需改两处前端文件
|
||||
4. **测试覆盖不足**:仅 `constants/` 下 4 个纯函数有测试,`utils/time.ts` 和组件内统计逻辑未覆盖
|
||||
5. **小问题**:`StatusDonut` 用数组索引做 key、`StatusBar` 硬编码 30 格、`TrendChart` 冗余 loading prop
|
||||
|
||||
后端 `CheckerRegistry` 已有 `supportedTypes` 属性,可直接暴露给前端。项目未上线,不需要向前兼容。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- hook 按职责拆分,文件名匹配实际内容
|
||||
- `TargetDetailDrawer` 拆分为 3 个组件,每个 < 100 行
|
||||
- 类型筛选器由后端 meta API 驱动,新增 checker 前端零改动
|
||||
- 删除 type label 转换层,直接使用 type 原始文本
|
||||
- 补齐前端纯函数测试
|
||||
- 修复已知小问题
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不引入路由库或状态管理库
|
||||
- 不重构后端 API 结构
|
||||
- 不改变现有轮询策略和 QueryClient 配置
|
||||
- 不新增 CSS 文件(继续使用单一 `styles.css`)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: hook 拆分策略
|
||||
|
||||
**选择**:按查询层级拆分为 `use-queries.ts` 和 `use-target-detail.ts`
|
||||
|
||||
- `use-queries.ts`:`queryKeys`、`fetchJson`(不导出)、`useSummary`、`useTargets`、`useMeta`
|
||||
- `use-target-detail.ts`:仅保留 Drawer 状态管理(`selectedTargetId`、时间范围、分页、`openDrawer`/`closeDrawer`)和条件查询(trend/history)
|
||||
|
||||
**理由**:全局查询(summary/targets/meta)是面板级别的,与 Drawer 详情无关。拆分后各文件职责单一,命名自解释。`fetchJson` 仅被 query 层使用,留在 `use-queries.ts` 内部不导出。
|
||||
|
||||
**备选方案**:
|
||||
- 仅重命名为 `useQueries.ts` — 解决命名问题但不解决职责混合
|
||||
- 每个 query 一个文件 — 过度拆分,增加文件数量但无实质收益
|
||||
|
||||
### Decision 2: TargetDetailDrawer 拆分方式
|
||||
|
||||
**选择**:拆为 3 个组件 + 1 个常量文件
|
||||
|
||||
```
|
||||
TargetDetailDrawer.tsx ← Drawer 壳 + 时间选择 + Tab 切换
|
||||
OverviewTab.tsx ← 统计 + TrendChart + StatusDonut + Descriptions
|
||||
HistoryTab.tsx ← PrimaryTable + 分页
|
||||
constants/history-table-columns.tsx ← HISTORY_COLUMNS
|
||||
```
|
||||
|
||||
**理由**:两个 Tab 的内容完全独立,拆分后各组件 < 100 行。`HISTORY_COLUMNS` 与 `TARGET_TABLE_COLUMNS` 性质相同,应放在 `constants/` 下保持一致。
|
||||
|
||||
统计计算逻辑(`totalChecks`/`upChecks`/`downChecks`)提取为 `utils/stats.ts` 纯函数,便于测试和 `useMemo`。
|
||||
|
||||
### Decision 3: Meta API 设计
|
||||
|
||||
**选择**:`GET /api/meta` 返回 `{ checkerTypes: string[] }`
|
||||
|
||||
```typescript
|
||||
// src/shared/api.ts
|
||||
export interface MetaResponse {
|
||||
checkerTypes: string[];
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 从 `checkerRegistry.supportedTypes` 直接获取,无需额外维护
|
||||
- 返回 `string[]` 而非 `{ key, label }[]`,因为决策是不做 label 转换
|
||||
- 端点命名为 `/api/meta` 而非 `/api/types`,为未来扩展预留空间(如版本号、功能开关等)
|
||||
- `staleTime: Infinity`,应用生命周期内只请求一次
|
||||
|
||||
**备选方案**:
|
||||
- 从 targets 响应中动态提取 — 只能获取"当前有数据的类型",不能获取"系统支持的全部类型"
|
||||
- 在 health 端点中附带 — 语义不匹配,health 应保持最小化
|
||||
|
||||
### Decision 4: 类型展示策略
|
||||
|
||||
**选择**:删除 `target-type-display.ts`,所有展示位置直接使用 `target.type` 原始文本
|
||||
|
||||
**影响位置**:
|
||||
- `target-table-columns.tsx` 类型列 cell:`row.type` 直接渲染
|
||||
- `TargetDetailDrawer.tsx` 标题栏 Tag:`target.type` 直接渲染
|
||||
- `typeFilter` 列表:从 meta API 获取,label 和 value 均为原始 type 文本
|
||||
|
||||
**理由**:type 文本本身已足够清晰(`http`、`command`、`tcp`),无需额外映射层。消除了前后端重复维护的问题。
|
||||
|
||||
### Decision 5: 列定义动态化
|
||||
|
||||
**选择**:`TARGET_TABLE_COLUMNS` 从静态常量改为工厂函数
|
||||
|
||||
```typescript
|
||||
export function createTargetTableColumns(checkerTypes: string[]): PrimaryTableCol<TargetStatus>[] {
|
||||
const typeFilter = {
|
||||
list: [
|
||||
{ label: "全部", value: "" },
|
||||
...checkerTypes.map(t => ({ label: t, value: t })),
|
||||
],
|
||||
type: "single" as const,
|
||||
};
|
||||
// ... 返回列定义数组
|
||||
}
|
||||
```
|
||||
|
||||
**数据流**:`useMeta()` → `checkerTypes` → `createTargetTableColumns(checkerTypes)` → `TargetGroup` columns prop
|
||||
|
||||
**理由**:`typeFilter` 是唯一需要动态数据的部分,通过工厂函数注入参数,保持列定义的纯函数特性。`statusFilter` 保持静态(UP/DOWN 是固定的)。
|
||||
|
||||
`TargetGroup` 新增 `columns` prop 接收动态列定义,`TargetBoard` 负责调用工厂函数并传递。
|
||||
|
||||
### Decision 6: StatusBar maxSlots 参数化
|
||||
|
||||
**选择**:新增 `maxSlots` prop(默认 30),组件根据 prop 渲染格数
|
||||
|
||||
```typescript
|
||||
interface StatusBarProps {
|
||||
samples: Array<{ up: boolean }>;
|
||||
maxSlots?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:消除硬编码魔数,使组件可复用。默认值 30 保持向后兼容。
|
||||
|
||||
### Decision 7: TrendChart 移除 loading prop
|
||||
|
||||
**选择**:移除 `loading` prop,组件只接收 `data: TrendPoint[]`
|
||||
|
||||
**理由**:调用方(`OverviewTab`)已用 `Skeleton` 处理 loading 状态,`TrendChart` 只在有数据时渲染。组件内部的 `if (loading)` 分支和 `loading={false}` 传参都是死代码。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| 列定义改为函数后,每次 `checkerTypes` 变化会重新创建列数组 | `useMemo` 包裹 `createTargetTableColumns` 调用;meta 数据 `staleTime: Infinity` 确保不会频繁变化 |
|
||||
| meta API 在 targets 之前未返回时,筛选器暂时为空 | meta 请求极轻量(无 DB 查询),通常先于 targets 返回;即使晚到,筛选器会在数据到达后自动出现 |
|
||||
| 拆分后组件间 props 传递增多 | 层级仅增加一层(Drawer → Tab),props 类型明确,不会造成 prop drilling 问题 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。方案已在 explore 阶段与用户确认。
|
||||
@@ -1,34 +0,0 @@
|
||||
## Why
|
||||
|
||||
前端代码经过多轮功能迭代后,出现了 hook 职责过重(`useTargetDetail.ts` 承载全部数据层)、组件体积膨胀(`TargetDetailDrawer` 228 行混合多种逻辑)、类型筛选器与后端硬编码重复维护等架构问题。需要通过拆分、动态化和测试补齐来提升可维护性,使新增 checker 类型时前端零改动。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 拆分 `hooks/useTargetDetail.ts` 为 `use-queries.ts`(全局查询)和 `use-target-detail.ts`(Drawer 状态管理)
|
||||
- 拆分 `TargetDetailDrawer.tsx` 为 Drawer 壳、`OverviewTab.tsx`、`HistoryTab.tsx` 三个组件
|
||||
- 将 `HISTORY_COLUMNS` 移至 `constants/history-table-columns.tsx`
|
||||
- 提取统计计算逻辑为 `utils/stats.ts` 纯函数
|
||||
- 后端新增 `GET /api/meta` 端点,返回 `checkerTypes` 列表
|
||||
- 前端新增 `useMeta()` hook 消费 meta API,动态生成类型筛选器
|
||||
- **BREAKING** 删除 `constants/target-type-display.ts`,前端直接使用 type 原始文本,不再做 label 转换
|
||||
- 列定义从静态常量改为工厂函数 `createTargetTableColumns(checkerTypes)`
|
||||
- 修复小问题:`StatusDonut` key、`StatusBar` 硬编码、`TrendChart` 冗余 prop、统计计算 useMemo
|
||||
- 补充前端测试:`utils/time.ts`、`utils/stats.ts`、动态列生成
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `meta-api`: 后端 meta 信息 API,提供 checker 类型列表等运行时元数据
|
||||
|
||||
### Modified Capabilities
|
||||
- `target-type-display`: 移除前端静态映射,改为直接使用后端返回的 type 原始文本,筛选器列表由 meta API 动态驱动
|
||||
- `tanstack-query-data-layer`: hook 文件拆分为 `use-queries.ts` 和 `use-target-detail.ts`,新增 `useMeta()` 查询
|
||||
- `target-detail-drawer`: 组件拆分为 Drawer 壳 + OverviewTab + HistoryTab,统计计算提取为纯函数
|
||||
- `target-table`: 列定义从静态常量改为工厂函数,接收动态 checkerTypes 参数;类型列直接显示 type 原始文本
|
||||
|
||||
## Impact
|
||||
|
||||
- 后端:新增 `src/server/routes/meta.ts`、`src/shared/api.ts` 增加 `MetaResponse` 类型、`app.ts` 注册路由
|
||||
- 前端:hooks/components/constants 目录结构调整,删除 `target-type-display.ts`
|
||||
- 测试:删除 `target-type-display.test.ts`,新增 `time.test.ts`、`stats.test.ts`,更新 `target-table-filters.test.ts`
|
||||
- 文档:更新 DEVELOPMENT.md 中前端目录结构和组件清单
|
||||
@@ -1,109 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: TanStack Query 数据层
|
||||
前端 SHALL 使用 TanStack Query(@tanstack/react-query)管理所有 API 请求,数据层代码 SHALL 按职责拆分为独立 hook 文件。
|
||||
|
||||
#### Scenario: QueryClient 配置
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** 系统 SHALL 创建 QueryClient,默认配置 retry=1、refetchOnWindowFocus=true、staleTime=5000
|
||||
|
||||
#### Scenario: QueryClientProvider 挂载
|
||||
- **WHEN** 应用渲染
|
||||
- **THEN** 根组件 SHALL 包裹在 QueryClientProvider 中,提供 QueryClient 实例
|
||||
|
||||
### Requirement: queryKey 工厂
|
||||
系统 SHALL 提供统一的 queryKey 工厂函数,确保 queryKey 的唯一性和一致性。
|
||||
|
||||
#### Scenario: summary queryKey
|
||||
- **WHEN** 查询 summary 数据
|
||||
- **THEN** queryKey SHALL 为 ["summary"]
|
||||
|
||||
#### Scenario: targets queryKey
|
||||
- **WHEN** 查询 targets 数据
|
||||
- **THEN** queryKey SHALL 为 ["targets"]
|
||||
|
||||
#### Scenario: meta queryKey
|
||||
- **WHEN** 查询 meta 数据
|
||||
- **THEN** queryKey SHALL 为 ["meta"]
|
||||
|
||||
#### Scenario: trend queryKey
|
||||
- **WHEN** 查询某目标的趋势数据
|
||||
- **THEN** queryKey SHALL 为 ["trend", targetId, from, to]
|
||||
|
||||
#### Scenario: history queryKey
|
||||
- **WHEN** 查询某目标的历史记录
|
||||
- **THEN** queryKey SHALL 为 ["history", targetId, from, to, page]
|
||||
|
||||
### Requirement: Hook 文件拆分
|
||||
数据层 hook SHALL 按职责拆分为独立文件。
|
||||
|
||||
#### Scenario: 全局查询 hook 文件
|
||||
- **WHEN** 开发者需要使用全局面板级查询
|
||||
- **THEN** `useSummary`、`useTargets`、`useMeta` SHALL 从 `hooks/use-queries.ts` 导出
|
||||
|
||||
#### Scenario: Drawer 状态 hook 文件
|
||||
- **WHEN** 开发者需要使用 Drawer 状态管理
|
||||
- **THEN** `useTargetDetail` SHALL 从 `hooks/use-target-detail.ts` 导出
|
||||
|
||||
#### Scenario: fetchJson 不导出
|
||||
- **WHEN** 数据层内部需要 fetch 封装
|
||||
- **THEN** `fetchJson` SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
|
||||
|
||||
#### Scenario: queryKeys 不导出
|
||||
- **WHEN** 数据层内部需要 query key
|
||||
- **THEN** `queryKeys` 对象 SHALL 定义在 `use-queries.ts` 内部,不作为公共 API 导出
|
||||
|
||||
### Requirement: Meta 查询
|
||||
系统 SHALL 提供 `useMeta` hook 查询系统元数据。
|
||||
|
||||
#### Scenario: meta 查询配置
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** `useMeta` SHALL 请求 `/api/meta`,配置 `staleTime: Infinity`(应用生命周期内只请求一次)
|
||||
|
||||
#### Scenario: meta 数据返回
|
||||
- **WHEN** meta 查询成功
|
||||
- **THEN** hook SHALL 返回 `MetaResponse` 类型数据,包含 `checkerTypes` 字段
|
||||
|
||||
### Requirement: Summary 轮询查询
|
||||
系统 SHALL 使用 useQuery 实现总览统计的自动轮询。
|
||||
|
||||
#### Scenario: summary 自动轮询
|
||||
- **WHEN** Dashboard 页面处于打开状态
|
||||
- **THEN** 系统 SHALL 每 8 秒自动请求 /api/summary,使用 refetchInterval=8000
|
||||
|
||||
#### Scenario: summary 后台刷新
|
||||
- **WHEN** 页面处于后台标签页
|
||||
- **THEN** 系统 SHALL 暂停轮询(refetchIntervalInBackground=false)
|
||||
|
||||
### Requirement: Targets 轮询查询
|
||||
系统 SHALL 使用 useQuery 实现目标列表的自动轮询。
|
||||
|
||||
#### Scenario: targets 自动轮询
|
||||
- **WHEN** Dashboard 页面处于打开状态
|
||||
- **THEN** 系统 SHALL 每 8 秒自动请求 /api/targets,使用 refetchInterval=8000
|
||||
|
||||
### Requirement: 条件查询
|
||||
趋势和历史记录查询 SHALL 使用 enabled 条件控制,仅在目标被选中时触发。
|
||||
|
||||
#### Scenario: 未选中目标时不请求
|
||||
- **WHEN** 用户未点击任何目标表格行
|
||||
- **THEN** trend 和 history 的 useQuery SHALL enabled=false,不发起请求
|
||||
|
||||
#### Scenario: 选中目标时自动请求
|
||||
- **WHEN** 用户点击目标表格行
|
||||
- **THEN** trend 和 history 的 useQuery SHALL enabled=true,自动发起请求
|
||||
|
||||
#### Scenario: 时间范围变化时重新请求
|
||||
- **WHEN** 用户更改时间范围
|
||||
- **THEN** trend 和 history 的 useQuery SHALL 因 queryKey 变化自动重新请求
|
||||
|
||||
### Requirement: 开发调试面板
|
||||
开发环境下 SHALL 挂载 TanStack Query Devtools。
|
||||
|
||||
#### Scenario: 开发环境显示 Devtools
|
||||
- **WHEN** 应用在开发模式下运行
|
||||
- **THEN** 页面 SHALL 显示 ReactQueryDevtools 浮动面板
|
||||
|
||||
#### Scenario: 生产环境排除 Devtools
|
||||
- **WHEN** 应用在生产模式下构建
|
||||
- **THEN** ReactQueryDevtools SHALL 不被包含在产物中
|
||||
@@ -1,91 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 目标详情 Drawer
|
||||
Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示该目标的详细统计信息和检查记录。Drawer 标题栏和内容不使用内联 style。Drawer 内容 SHALL 拆分为独立的 Tab 组件。
|
||||
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right"),宽度为视口 60%
|
||||
|
||||
#### Scenario: Drawer 标题栏
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件(align="center")布局,包含 StatusDot、目标名称(TDesign Typography.Text strong)和类型标签(TDesign Tag,直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局
|
||||
|
||||
#### Scenario: 关闭 Drawer
|
||||
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层
|
||||
- **THEN** Drawer SHALL 关闭
|
||||
|
||||
#### Scenario: Drawer 无底部按钮
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** Drawer SHALL 不显示底部操作栏(footer={false})
|
||||
|
||||
#### Scenario: Drawer 数据同步
|
||||
- **WHEN** Drawer 打开期间后台轮询刷新了 targets 数据
|
||||
- **THEN** Drawer 中 selectedTarget 的状态 SHALL 随之同步更新
|
||||
|
||||
#### Scenario: 切换目标重置 Tab
|
||||
- **WHEN** 用户从目标 A 切换到目标 B(点击不同的表格行)
|
||||
- **THEN** Drawer SHALL 重置为概览 Tab,使用 key={target.id} 确保组件状态不残留
|
||||
|
||||
#### Scenario: Drawer 内容区间距
|
||||
- **WHEN** Drawer 内容渲染
|
||||
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件(direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
|
||||
|
||||
### Requirement: 概览面板组件化
|
||||
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,接收数据 props 进行渲染。
|
||||
|
||||
#### Scenario: OverviewTab 组件职责
|
||||
- **WHEN** 概览 Tab 渲染
|
||||
- **THEN** `OverviewTab` 组件 SHALL 负责统计卡片、趋势图、状态分布环形图和基本信息的渲染
|
||||
|
||||
#### Scenario: 统计计算使用纯函数
|
||||
- **WHEN** OverviewTab 需要计算 totalChecks、upChecks、downChecks
|
||||
- **THEN** 计算逻辑 SHALL 通过 `utils/stats.ts` 中的纯函数实现,并使用 `useMemo` 缓存结果
|
||||
|
||||
#### Scenario: OverviewTab props
|
||||
- **WHEN** OverviewTab 渲染
|
||||
- **THEN** 组件 SHALL 接收 `target: TargetStatus`、`trendData: TrendPoint[]`、`trendLoading: boolean` 作为 props
|
||||
|
||||
### Requirement: 记录面板组件化
|
||||
记录 Tab SHALL 作为独立组件 `HistoryTab` 实现。
|
||||
|
||||
#### Scenario: HistoryTab 组件职责
|
||||
- **WHEN** 记录 Tab 渲染
|
||||
- **THEN** `HistoryTab` 组件 SHALL 负责检查结果表格和分页的渲染
|
||||
|
||||
#### Scenario: HistoryTab props
|
||||
- **WHEN** HistoryTab 渲染
|
||||
- **THEN** 组件 SHALL 接收 `historyData: HistoryResponse`、`historyLoading: boolean`、`onPageChange: (page: number) => void` 作为 props
|
||||
|
||||
#### Scenario: 历史记录列定义外置
|
||||
- **WHEN** HistoryTab 渲染表格
|
||||
- **THEN** 列定义 SHALL 从 `constants/history-table-columns.tsx` 导入,不在组件内部定义
|
||||
|
||||
### Requirement: TrendChart 简化
|
||||
TrendChart 组件 SHALL 仅接收数据 props,不处理 loading 状态。
|
||||
|
||||
#### Scenario: TrendChart 无 loading prop
|
||||
- **WHEN** TrendChart 渲染
|
||||
- **THEN** 组件 SHALL 仅接收 `data: TrendPoint[]` prop,不接收 `loading` prop
|
||||
|
||||
#### Scenario: TrendChart 空数据
|
||||
- **WHEN** TrendChart 接收空数组
|
||||
- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本
|
||||
|
||||
### Requirement: StatusDonut key 修复
|
||||
StatusDonut 组件 SHALL 使用语义化的 key。
|
||||
|
||||
#### Scenario: Pie Cell key
|
||||
- **WHEN** StatusDonut 渲染 Pie Cell 列表
|
||||
- **THEN** 每个 Cell 的 key SHALL 使用 data item 的 `name` 字段,不使用数组索引
|
||||
|
||||
### Requirement: StatusBar 参数化
|
||||
StatusBar 组件 SHALL 支持可配置的格数。
|
||||
|
||||
#### Scenario: maxSlots prop
|
||||
- **WHEN** StatusBar 渲染
|
||||
- **THEN** 组件 SHALL 接收可选的 `maxSlots` prop(默认 30),根据该值渲染对应数量的格子
|
||||
|
||||
#### Scenario: 格子渲染逻辑
|
||||
- **WHEN** StatusBar 渲染且 samples 数量少于 maxSlots
|
||||
- **THEN** 多余的格子 SHALL 显示为 empty 状态
|
||||
@@ -1,69 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 表格列定义
|
||||
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
|
||||
|
||||
#### Scenario: 状态列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60px,fixed="left",居中对齐,支持筛选(UP/DOWN/全部)。StatusDot SHALL 通过 CSS 类(`.status-dot--up` / `.status-dot--down`)控制颜色,不使用内联 style
|
||||
|
||||
#### Scenario: 名称列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 名称列 SHALL 显示目标名称,支持字母排序(zh-CN),ellipsis 超长名称自动省略并 Tooltip 显示全名
|
||||
|
||||
#### Scenario: 类型列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 类型列 SHALL 使用 TDesign Tag 组件(size=small, theme=primary, variant=light-outline)直接显示 target.type 原始文本,支持单选筛选
|
||||
|
||||
#### Scenario: 类型筛选器动态生成
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 类型列的筛选器列表 SHALL 从 meta API 返回的 `checkerTypes` 动态生成,包含"全部"选项和每个 checker 类型选项(label 和 value 均为 type 原始文本)
|
||||
|
||||
#### Scenario: 可用率列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 可用率列 SHALL 使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色通过 CSS 自定义属性 `--avail-N`(基于项目自定义色值)控制,每 10% 一档,label 显示百分比数值,支持排序(升序优先,最差排最前)。color-threshold 函数 SHALL 返回 CSS 自定义属性引用而非硬编码色值
|
||||
|
||||
#### Scenario: 最近状态列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style
|
||||
|
||||
#### Scenario: 延迟列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐。颜色 SHALL 通过 CSS 类实现:≤100ms 使用 `.latency-ok`、100-500ms 使用 `.latency-warn`、>500ms 使用 `.latency-error`。无数据 SHALL 使用 `.text-disabled` 类显示 "-",数值 SHALL 使用 `.tabular-nums` 类等宽显示。不使用内联 style
|
||||
|
||||
#### Scenario: 间隔列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
|
||||
|
||||
### Requirement: 列定义工厂函数
|
||||
列定义 SHALL 通过工厂函数生成,接收动态参数。
|
||||
|
||||
#### Scenario: createTargetTableColumns 函数
|
||||
- **WHEN** 需要生成表格列定义
|
||||
- **THEN** 系统 SHALL 调用 `createTargetTableColumns(checkerTypes: string[])` 函数,返回 `PrimaryTableCol<TargetStatus>[]`
|
||||
|
||||
#### Scenario: checkerTypes 为空数组
|
||||
- **WHEN** meta API 尚未返回或返回空数组
|
||||
- **THEN** 类型列的筛选器 SHALL 仅包含"全部"选项
|
||||
|
||||
#### Scenario: 列定义缓存
|
||||
- **WHEN** TargetBoard 组件渲染
|
||||
- **THEN** 列定义 SHALL 通过 `useMemo` 缓存,仅在 `checkerTypes` 变化时重新生成
|
||||
|
||||
### Requirement: TargetGroup 接收 columns prop
|
||||
TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态常量。
|
||||
|
||||
#### Scenario: columns prop
|
||||
- **WHEN** TargetGroup 渲染
|
||||
- **THEN** 组件 SHALL 接收 `columns: PrimaryTableCol<TargetStatus>[]` prop 并传递给 PrimaryTable
|
||||
|
||||
#### Scenario: TargetBoard 传递 columns
|
||||
- **WHEN** TargetBoard 渲染子组件
|
||||
- **THEN** TargetBoard SHALL 调用 `createTargetTableColumns` 生成列定义并传递给每个 TargetGroup
|
||||
|
||||
### Requirement: 列定义复用
|
||||
所有分组的表格 SHALL 共享同一套列定义常量。
|
||||
|
||||
#### Scenario: 列定义提取为常量
|
||||
- **WHEN** 多个分组表格渲染
|
||||
- **THEN** 列定义 SHALL 从独立的 constants/target-table-columns.tsx 导入,不在组件中重复定义
|
||||
@@ -1,13 +0,0 @@
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 类型显示名称映射
|
||||
**Reason**: 前端不再维护 type → label 的静态映射,直接使用后端返回的 type 原始文本展示。类型筛选器列表改由 meta API 动态驱动。
|
||||
**Migration**: 所有使用 `getTargetTypeDisplay(type)` 的位置改为直接使用 `type` 字符串。`TARGET_TYPE_DISPLAY` 常量和 `target-type-display.ts` 文件删除。
|
||||
|
||||
### Requirement: 映射可扩展性
|
||||
**Reason**: 不再需要前端映射扩展机制,新增 checker 类型时后端注册即自动通过 meta API 暴露给前端。
|
||||
**Migration**: 无需迁移,删除即可。
|
||||
|
||||
### Requirement: 类型安全
|
||||
**Reason**: 不再有映射常量,无需 TypeScript 类型推导和 fallback 逻辑。
|
||||
**Migration**: 无需迁移,删除即可。
|
||||
@@ -1,48 +0,0 @@
|
||||
## 1. 后端 Meta API
|
||||
|
||||
- [ ] 1.1 在 `src/shared/api.ts` 中新增 `MetaResponse` 类型定义
|
||||
- [ ] 1.2 创建 `src/server/routes/meta.ts`,实现 `handleMeta` 从 `checkerRegistry.supportedTypes` 返回数据
|
||||
- [ ] 1.3 在 `src/server/app.ts` 中注册 `/api/meta` 路由
|
||||
- [ ] 1.4 在 `tests/server/app.test.ts` 中添加 `/api/meta` 端点测试(GET/HEAD/405)
|
||||
|
||||
## 2. 前端 Hook 拆分
|
||||
|
||||
- [ ] 2.1 创建 `src/web/hooks/use-queries.ts`,迁入 `queryKeys`、`fetchJson`、`useSummary`、`useTargets`,新增 `useMeta`
|
||||
- [ ] 2.2 重写 `src/web/hooks/use-target-detail.ts`,仅保留 Drawer 状态管理和条件查询(trend/history)
|
||||
- [ ] 2.3 更新 `src/web/app.tsx` 的 import 路径适配新 hook 文件
|
||||
|
||||
## 3. 前端组件拆分
|
||||
|
||||
- [ ] 3.1 创建 `src/web/utils/stats.ts`,提取统计计算纯函数(`computeTrendStats`)
|
||||
- [ ] 3.2 创建 `src/web/constants/history-table-columns.tsx`,将 `HISTORY_COLUMNS` 从 Drawer 中移出
|
||||
- [ ] 3.3 创建 `src/web/components/OverviewTab.tsx`,从 TargetDetailDrawer 中提取概览面板逻辑
|
||||
- [ ] 3.4 创建 `src/web/components/HistoryTab.tsx`,从 TargetDetailDrawer 中提取记录面板逻辑
|
||||
- [ ] 3.5 精简 `src/web/components/TargetDetailDrawer.tsx`,仅保留 Drawer 壳 + 时间选择 + Tab 切换
|
||||
|
||||
## 4. 类型筛选器动态化
|
||||
|
||||
- [ ] 4.1 将 `src/web/constants/target-table-columns.tsx` 中的 `TARGET_TABLE_COLUMNS` 改为工厂函数 `createTargetTableColumns(checkerTypes)`
|
||||
- [ ] 4.2 从 `src/web/constants/target-table-filters.ts` 中移除 `typeFilter`(`statusFilter` 保留)
|
||||
- [ ] 4.3 更新 `src/web/components/TargetBoard.tsx`,调用 `useMeta` + `useMemo` 生成列定义并传递给 TargetGroup
|
||||
- [ ] 4.4 更新 `src/web/components/TargetGroup.tsx`,新增 `columns` prop 替代静态导入
|
||||
- [ ] 4.5 删除 `src/web/constants/target-type-display.ts`
|
||||
- [ ] 4.6 更新 `src/web/components/TargetDetailDrawer.tsx` 标题栏,直接使用 `target.type` 替代 `getTargetTypeDisplay`
|
||||
|
||||
## 5. 小问题修复
|
||||
|
||||
- [ ] 5.1 修复 `StatusDonut.tsx`:Cell key 从 `index` 改为 `data[index].name`
|
||||
- [ ] 5.2 修复 `StatusBar.tsx`:新增 `maxSlots` prop(默认 30),用 prop 驱动格数渲染
|
||||
- [ ] 5.3 修复 `TrendChart.tsx`:移除 `loading` prop,仅保留 `data` prop
|
||||
|
||||
## 6. 测试补充与更新
|
||||
|
||||
- [ ] 6.1 创建 `tests/web/utils/time.test.ts`,测试 `subtractHours`(正常、跨天、跨月、0 小时)
|
||||
- [ ] 6.2 创建 `tests/web/utils/stats.test.ts`,测试 `computeTrendStats` 纯函数
|
||||
- [ ] 6.3 更新 `tests/web/constants/target-table-filters.test.ts`,移除 `typeFilter` 相关测试
|
||||
- [ ] 6.4 删除 `tests/web/constants/target-type-display.test.ts`
|
||||
- [ ] 6.5 创建 `tests/web/constants/target-table-columns.test.ts`,测试 `createTargetTableColumns` 工厂函数
|
||||
|
||||
## 7. 质量保障与文档
|
||||
|
||||
- [ ] 7.1 执行 `bun run check` 确保类型检查、lint、测试全部通过
|
||||
- [ ] 7.2 更新 DEVELOPMENT.md 中前端目录结构、组件清单和 hook 说明
|
||||
@@ -1,4 +1,8 @@
|
||||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
定义系统运行时元数据 API:checker 类型列表等元信息的对外暴露方式。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Meta 信息 API
|
||||
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。
|
||||
@@ -130,3 +130,29 @@
|
||||
#### Scenario: 无失败信息
|
||||
- **WHEN** 检查结果 matched=true
|
||||
- **THEN** API SHALL 返回 failure 为 null
|
||||
|
||||
### Requirement: Meta 信息 API
|
||||
系统 SHALL 提供 `GET /api/meta` 端点,返回系统运行时元数据。
|
||||
|
||||
#### Scenario: 获取 checker 类型列表
|
||||
- **WHEN** 客户端请求 `GET /api/meta`
|
||||
- **THEN** 系统 SHALL 返回 JSON `{ checkerTypes: string[] }`,包含所有已注册的 checker 类型标识符
|
||||
|
||||
#### 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 header,body 为空
|
||||
|
||||
### Requirement: MetaResponse 共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 `MetaResponse` 类型。
|
||||
|
||||
#### Scenario: MetaResponse 类型定义
|
||||
- **WHEN** 前后端引用 `MetaResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `checkerTypes: string[]` 字段
|
||||
|
||||
@@ -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 实现总览统计的自动轮询。
|
||||
|
||||
|
||||
@@ -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 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。
|
||||
|
||||
|
||||
@@ -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** 多个分组表格渲染
|
||||
|
||||
@@ -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 值,不抛出异常
|
||||
@@ -5,6 +5,7 @@ import { createApiError, jsonResponse } from "./helpers";
|
||||
import { guardGetHead } from "./middleware";
|
||||
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";
|
||||
@@ -29,6 +30,10 @@ export function createFetchHandler(options: AppOptions) {
|
||||
return handleHealth(request.method, options.mode);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/meta") {
|
||||
return handleMetaRoute(request, options.mode);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/") && options.store) {
|
||||
return handleApiRoute(url, request, options.store, options.mode);
|
||||
}
|
||||
@@ -78,3 +83,9 @@ function handleApiRoute(url: URL, request: Request, store: ProbeStore, mode: Run
|
||||
|
||||
return jsonResponse(createApiError("API route not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
function handleMetaRoute(request: Request, mode: RuntimeMode): Response {
|
||||
const guardResult = guardGetHead(request.method, mode);
|
||||
if (guardResult) return guardResult;
|
||||
return handleMeta(request.method, mode);
|
||||
}
|
||||
|
||||
12
src/server/routes/meta.ts
Normal file
12
src/server/routes/meta.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { MetaResponse, RuntimeMode } from "../../shared/api";
|
||||
|
||||
import { checkerRegistry } from "../checker/runner";
|
||||
import { jsonResponse } from "../helpers";
|
||||
|
||||
export function handleMeta(method: string, mode: RuntimeMode): Response {
|
||||
const response: MetaResponse = {
|
||||
checkerTypes: checkerRegistry.supportedTypes,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { method, mode });
|
||||
}
|
||||
@@ -33,6 +33,10 @@ export interface HistoryResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MetaResponse {
|
||||
checkerTypes: string[];
|
||||
}
|
||||
|
||||
export interface RecentSample {
|
||||
durationMs: null | number;
|
||||
timestamp: string;
|
||||
|
||||
@@ -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();
|
||||
|
||||
31
src/web/components/HistoryTab.tsx
Normal file
31
src/web/components/HistoryTab.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
|
||||
import type { HistoryResponse } from "../../shared/api";
|
||||
|
||||
import { HISTORY_COLUMNS } from "../constants/history-table-columns";
|
||||
|
||||
interface HistoryTabProps {
|
||||
historyData: HistoryResponse;
|
||||
historyLoading: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function HistoryTab({ historyData, historyLoading, onPageChange }: HistoryTabProps) {
|
||||
return (
|
||||
<PrimaryTable
|
||||
columns={HISTORY_COLUMNS}
|
||||
data={historyData.items}
|
||||
disableDataPage
|
||||
loading={historyLoading}
|
||||
onPageChange={({ current }) => {
|
||||
if (current) onPageChange(current);
|
||||
}}
|
||||
pagination={{
|
||||
current: historyData.page,
|
||||
pageSize: historyData.pageSize,
|
||||
total: historyData.total,
|
||||
}}
|
||||
rowKey="timestamp"
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
src/web/components/OverviewTab.tsx
Normal file
57
src/web/components/OverviewTab.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from "react";
|
||||
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus, TrendPoint } from "../../shared/api";
|
||||
|
||||
import { computeTrendStats } from "../utils/stats";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface OverviewTabProps {
|
||||
target: TargetStatus;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
}
|
||||
|
||||
export function OverviewTab({ target, trendData, trendLoading }: OverviewTabProps) {
|
||||
const { downChecks, totalChecks, upChecks } = useMemo(() => computeTrendStats(trendData), [trendData]);
|
||||
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Divider align="left">统计</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic color="blue" title="总检查" value={totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" title="正常" value={upChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" title="异常" value={downChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={target.stats?.availability ?? 0} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{trendLoading ? <Skeleton animation="gradient" /> : <TrendChart data={trendData} />}
|
||||
|
||||
<Divider align="left">状态分布</Divider>
|
||||
<StatusDonut down={downChecks} up={upChecks} />
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
items={[
|
||||
{ content: target.target, label: "目标地址" },
|
||||
{ content: target.interval, label: "检查间隔" },
|
||||
{
|
||||
content: target.latestCheck ? new Date(target.latestCheck.timestamp).toLocaleString("zh-CN") : "-",
|
||||
label: "最新检查时间",
|
||||
},
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
interface StatusBarProps {
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||
<OverviewTab target={target} trendData={trendData} trendLoading={trendLoading} />
|
||||
</Tabs.TabPanel>
|
||||
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="记录" value="history">
|
||||
<HistoryTab historyData={historyData} historyLoading={historyLoading} onPageChange={onPageChange} />
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
</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>
|
||||
</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"
|
||||
/>
|
||||
</Tabs.TabPanel>
|
||||
</Tabs>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
42
src/web/constants/history-table-columns.tsx
Normal file
42
src/web/constants/history-table-columns.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
import type { CheckResult } from "../../shared/api";
|
||||
|
||||
import { StatusDot } from "../components/StatusDot";
|
||||
|
||||
export const HISTORY_COLUMNS: Array<PrimaryTableCol<CheckResult>> = [
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => <StatusDot up={!!row.matched} />,
|
||||
colKey: "matched",
|
||||
title: "#",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => formatTimestamp(row.timestamp),
|
||||
colKey: "timestamp",
|
||||
title: "时间",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
cell: ({ row }: PrimaryTableCellParams<CheckResult>) =>
|
||||
row.durationMs !== null ? Math.round(row.durationMs) : "-",
|
||||
colKey: "durationMs",
|
||||
title: "耗时(ms)",
|
||||
width: 96,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<CheckResult>) => {
|
||||
const parts = [row.statusDetail, row.failure?.message].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(":") : "-";
|
||||
},
|
||||
colKey: "statusDetail",
|
||||
title: "详情",
|
||||
},
|
||||
];
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
@@ -7,86 +7,91 @@ 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>> = [
|
||||
{
|
||||
align: "center",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
||||
colKey: "latestCheck.matched",
|
||||
filter: statusFilter,
|
||||
fixed: "left",
|
||||
title: "#",
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
colKey: "name",
|
||||
ellipsis: true,
|
||||
sorter: nameSorter,
|
||||
sortType: "all",
|
||||
title: "名称",
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{getTargetTypeDisplay(row.type)}
|
||||
</Tag>
|
||||
),
|
||||
colKey: "type",
|
||||
filter: typeFilter,
|
||||
title: "类型",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const availability = row.stats?.availability;
|
||||
if (availability === undefined || availability === null) return "-";
|
||||
const color = getAvailabilityProgressColor(availability);
|
||||
return (
|
||||
<Progress
|
||||
color={color}
|
||||
label={`${availability.toFixed(1)}%`}
|
||||
percentage={availability}
|
||||
size="small"
|
||||
theme="line"
|
||||
/>
|
||||
);
|
||||
export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> {
|
||||
return [
|
||||
{
|
||||
align: "center",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusDot up={!!row.latestCheck?.matched} />,
|
||||
colKey: "latestCheck.matched",
|
||||
filter: statusFilter,
|
||||
fixed: "left",
|
||||
title: "#",
|
||||
width: 60,
|
||||
},
|
||||
colKey: "stats.availability",
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
title: "可用率",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
|
||||
colKey: "recentSamples",
|
||||
title: "最近状态",
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
align: "right",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const ms = row.latestCheck?.durationMs;
|
||||
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
|
||||
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
|
||||
return <span className={`${colorClass} tabular-nums`}>{Math.round(ms)}ms</span>;
|
||||
{
|
||||
colKey: "name",
|
||||
ellipsis: true,
|
||||
sorter: nameSorter,
|
||||
sortType: "all",
|
||||
title: "名称",
|
||||
},
|
||||
colKey: "latestCheck.durationMs",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
colKey: "interval",
|
||||
title: "间隔",
|
||||
width: 72,
|
||||
},
|
||||
];
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => (
|
||||
<Tag size="small" theme="primary" variant="light-outline">
|
||||
{row.type}
|
||||
</Tag>
|
||||
),
|
||||
colKey: "type",
|
||||
filter: createTypeFilter(checkerTypes),
|
||||
title: "类型",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const availability = row.stats?.availability;
|
||||
if (availability === undefined || availability === null) return "-";
|
||||
const color = getAvailabilityProgressColor(availability);
|
||||
return (
|
||||
<Progress
|
||||
color={color}
|
||||
label={`${availability.toFixed(1)}%`}
|
||||
percentage={availability}
|
||||
size="small"
|
||||
theme="line"
|
||||
/>
|
||||
);
|
||||
},
|
||||
colKey: "stats.availability",
|
||||
sorter: availabilitySorter,
|
||||
sortType: "all",
|
||||
title: "可用率",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => <StatusBar samples={row.recentSamples} />,
|
||||
colKey: "recentSamples",
|
||||
title: "最近状态",
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
align: "right",
|
||||
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => {
|
||||
const ms = row.latestCheck?.durationMs;
|
||||
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
|
||||
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
|
||||
return <span className={`${colorClass} tabular-nums`}>{Math.round(ms)}ms</span>;
|
||||
},
|
||||
colKey: "latestCheck.durationMs",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
colKey: "interval",
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
41
src/web/hooks/use-queries.ts
Normal file
41
src/web/hooks/use-queries.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { MetaResponse, SummaryResponse, TargetStatus } from "../../shared/api";
|
||||
|
||||
const queryKeys = {
|
||||
meta: () => ["meta"] as const,
|
||||
summary: () => ["summary"] as const,
|
||||
targets: () => ["targets"] as const,
|
||||
};
|
||||
|
||||
export async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function useMeta() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<MetaResponse>("/api/meta"),
|
||||
queryKey: queryKeys.meta(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSummary() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<SummaryResponse>("/api/summary"),
|
||||
queryKey: queryKeys.summary(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTargets() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<TargetStatus[]>("/api/targets"),
|
||||
queryKey: queryKeys.targets(),
|
||||
refetchInterval: 8000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
@@ -1,26 +1,16 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { 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>;
|
||||
}
|
||||
23
src/web/utils/stats.ts
Normal file
23
src/web/utils/stats.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { TrendPoint } from "../../shared/api";
|
||||
|
||||
export interface TrendStats {
|
||||
downChecks: number;
|
||||
totalChecks: number;
|
||||
upChecks: number;
|
||||
}
|
||||
|
||||
export function computeTrendStats(points: TrendPoint[]): TrendStats {
|
||||
let totalChecks = 0;
|
||||
let upChecks = 0;
|
||||
|
||||
for (const point of points) {
|
||||
totalChecks += point.totalChecks;
|
||||
upChecks += Math.round((point.availability / 100) * point.totalChecks);
|
||||
}
|
||||
|
||||
return {
|
||||
downChecks: totalChecks - upChecks,
|
||||
totalChecks,
|
||||
upChecks,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,13 @@ import { mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { HealthResponse, HistoryResponse, SummaryResponse, TargetStatus } from "../../src/shared/api";
|
||||
import type {
|
||||
HealthResponse,
|
||||
HistoryResponse,
|
||||
MetaResponse,
|
||||
SummaryResponse,
|
||||
TargetStatus,
|
||||
} from "../../src/shared/api";
|
||||
|
||||
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
|
||||
import { checkerRegistry } from "../../src/server/checker/runner";
|
||||
@@ -151,6 +157,32 @@ describe("API 路由", () => {
|
||||
expect(tB.latestCheck).toBeNull();
|
||||
});
|
||||
|
||||
test("/api/meta 返回 checker 类型列表", async () => {
|
||||
const response = fetchHandler(new Request("http://localhost/api/meta"));
|
||||
const body = (await response.json()) as MetaResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.checkerTypes).toEqual(checkerRegistry.supportedTypes);
|
||||
expect(body.checkerTypes).toContain("http");
|
||||
expect(body.checkerTypes).toContain("command");
|
||||
});
|
||||
|
||||
test("/api/meta HEAD 请求返回 headers 无 body", async () => {
|
||||
const response = fetchHandler(new Request("http://localhost/api/meta", { method: "HEAD" }));
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("application/json");
|
||||
expect(body).toBe("");
|
||||
});
|
||||
|
||||
test("/api/meta 不支持的 method 返回 405", () => {
|
||||
const response = fetchHandler(new Request("http://localhost/api/meta", { method: "POST" }));
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
});
|
||||
|
||||
test("/api/targets/:id/history 返回历史记录", async () => {
|
||||
const targets = store.getTargets();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
|
||||
84
tests/web/constants/target-table-columns.test.ts
Normal file
84
tests/web/constants/target-table-columns.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { PrimaryTableCellParams, PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { TargetStatus } from "../../../src/shared/api";
|
||||
|
||||
import { createTargetTableColumns } from "../../../src/web/constants/target-table-columns";
|
||||
|
||||
interface TableFilter {
|
||||
list?: Array<{ label: string; value: string }>;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function getColumn(columns: Array<PrimaryTableCol<TargetStatus>>, colKey: string): PrimaryTableCol<TargetStatus> {
|
||||
const column = columns.find((item) => item.colKey === colKey);
|
||||
expect(column).toBeDefined();
|
||||
return column!;
|
||||
}
|
||||
|
||||
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
return {
|
||||
group: "default",
|
||||
id: 1,
|
||||
interval: "5s",
|
||||
latestCheck: null,
|
||||
name: "test",
|
||||
recentSamples: [],
|
||||
stats: { availability: 100, totalChecks: 0 },
|
||||
target: "https://example.com",
|
||||
type: "http",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createTargetTableColumns", () => {
|
||||
test("生成 7 个目标表格列", () => {
|
||||
const columns = createTargetTableColumns(["http", "command"]);
|
||||
|
||||
expect(columns.map((column) => column.colKey)).toEqual([
|
||||
"latestCheck.matched",
|
||||
"name",
|
||||
"type",
|
||||
"stats.availability",
|
||||
"recentSamples",
|
||||
"latestCheck.durationMs",
|
||||
"interval",
|
||||
]);
|
||||
});
|
||||
|
||||
test("根据 checkerTypes 生成类型筛选器", () => {
|
||||
const typeColumn = getColumn(createTargetTableColumns(["http", "command", "tcp"]), "type");
|
||||
const filter = typeColumn.filter as TableFilter;
|
||||
|
||||
expect(filter.type).toBe("single");
|
||||
expect(filter.list).toEqual([
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "http", value: "http" },
|
||||
{ label: "command", value: "command" },
|
||||
{ label: "tcp", value: "tcp" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("checkerTypes 为空时只保留全部选项", () => {
|
||||
const typeColumn = getColumn(createTargetTableColumns([]), "type");
|
||||
const filter = typeColumn.filter as TableFilter;
|
||||
|
||||
expect(filter.list).toEqual([{ label: "全部", value: "" }]);
|
||||
});
|
||||
|
||||
test("类型列直接渲染原始 type 文本", () => {
|
||||
const typeColumn = getColumn(createTargetTableColumns(["tcp"]), "type");
|
||||
const renderCell = typeColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
|
||||
props: { children: unknown };
|
||||
};
|
||||
const element = renderCell({
|
||||
col: typeColumn,
|
||||
colIndex: 2,
|
||||
row: makeTarget({ type: "tcp" }),
|
||||
rowIndex: 0,
|
||||
});
|
||||
|
||||
expect(element.props.children).toBe("tcp");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { statusFilter, typeFilter } from "../../../src/web/constants/target-table-filters";
|
||||
import { statusFilter } from "../../../src/web/constants/target-table-filters";
|
||||
|
||||
describe("target-table-filters", () => {
|
||||
describe("statusFilter", () => {
|
||||
@@ -14,16 +14,4 @@ describe("target-table-filters", () => {
|
||||
expect(list[2]!.label).toBe("DOWN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("typeFilter", () => {
|
||||
test("包含全部选项", () => {
|
||||
expect(typeFilter).toBeDefined();
|
||||
expect(typeFilter!.type).toBe("single");
|
||||
const list = typeFilter!.list!;
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[0]!.label).toBe("全部");
|
||||
expect(list[1]!.label).toBe("HTTP");
|
||||
expect(list[2]!.label).toBe("CMD");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { getTargetTypeDisplay, TARGET_TYPE_DISPLAY } from "../../../src/web/constants/target-type-display";
|
||||
|
||||
describe("target-type-display", () => {
|
||||
describe("TARGET_TYPE_DISPLAY 常量", () => {
|
||||
test("定义了 http 类型映射", () => {
|
||||
expect(TARGET_TYPE_DISPLAY.http).toBe("HTTP");
|
||||
});
|
||||
|
||||
test("定义了 command 类型映射", () => {
|
||||
expect(TARGET_TYPE_DISPLAY.command).toBe("CMD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTargetTypeDisplay 函数", () => {
|
||||
test("http 类型返回 HTTP", () => {
|
||||
expect(getTargetTypeDisplay("http")).toBe("HTTP");
|
||||
});
|
||||
|
||||
test("command 类型返回 CMD", () => {
|
||||
expect(getTargetTypeDisplay("command")).toBe("CMD");
|
||||
});
|
||||
|
||||
test("未知类型返回大写形式", () => {
|
||||
expect(getTargetTypeDisplay("tcp")).toBe("TCP");
|
||||
expect(getTargetTypeDisplay("dns")).toBe("DNS");
|
||||
expect(getTargetTypeDisplay("grpc")).toBe("GRPC");
|
||||
});
|
||||
|
||||
test("空字符串返回空字符串", () => {
|
||||
expect(getTargetTypeDisplay("")).toBe("");
|
||||
});
|
||||
|
||||
test("已大写的类型保持大写", () => {
|
||||
expect(getTargetTypeDisplay("HTTP")).toBe("HTTP");
|
||||
expect(getTargetTypeDisplay("CMD")).toBe("CMD");
|
||||
});
|
||||
});
|
||||
});
|
||||
29
tests/web/utils/stats.test.ts
Normal file
29
tests/web/utils/stats.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { TrendPoint } from "../../../src/shared/api";
|
||||
|
||||
import { computeTrendStats } from "../../../src/web/utils/stats";
|
||||
|
||||
describe("computeTrendStats", () => {
|
||||
test("空趋势返回 0 统计", () => {
|
||||
expect(computeTrendStats([])).toEqual({ downChecks: 0, totalChecks: 0, upChecks: 0 });
|
||||
});
|
||||
|
||||
test("汇总总检查、正常和异常数量", () => {
|
||||
const points: TrendPoint[] = [
|
||||
{ availability: 80, avgDurationMs: 100, hour: "2025-01-01T00:00:00.000Z", totalChecks: 10 },
|
||||
{ availability: 40, avgDurationMs: 200, hour: "2025-01-01T01:00:00.000Z", totalChecks: 5 },
|
||||
];
|
||||
|
||||
expect(computeTrendStats(points)).toEqual({ downChecks: 5, totalChecks: 15, upChecks: 10 });
|
||||
});
|
||||
|
||||
test("按每个趋势点四舍五入正常数量", () => {
|
||||
const points: TrendPoint[] = [
|
||||
{ availability: 33.3, avgDurationMs: null, hour: "2025-01-01T00:00:00.000Z", totalChecks: 3 },
|
||||
{ availability: 66.7, avgDurationMs: null, hour: "2025-01-01T01:00:00.000Z", totalChecks: 3 },
|
||||
];
|
||||
|
||||
expect(computeTrendStats(points)).toEqual({ downChecks: 3, totalChecks: 6, upChecks: 3 });
|
||||
});
|
||||
});
|
||||
29
tests/web/utils/time.test.ts
Normal file
29
tests/web/utils/time.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { subtractHours } from "../../../src/web/utils/time";
|
||||
|
||||
describe("subtractHours", () => {
|
||||
test("正常扣减小时", () => {
|
||||
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 3);
|
||||
|
||||
expect(result.toISOString()).toBe("2025-01-15T09:00:00.000Z");
|
||||
});
|
||||
|
||||
test("跨天扣减", () => {
|
||||
const result = subtractHours(new Date("2025-01-15T02:00:00.000Z"), 6);
|
||||
|
||||
expect(result.toISOString()).toBe("2025-01-14T20:00:00.000Z");
|
||||
});
|
||||
|
||||
test("跨月扣减", () => {
|
||||
const result = subtractHours(new Date("2025-03-01T01:00:00.000Z"), 2);
|
||||
|
||||
expect(result.toISOString()).toBe("2025-02-28T23:00:00.000Z");
|
||||
});
|
||||
|
||||
test("扣减 0 小时返回相同时间", () => {
|
||||
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 0);
|
||||
|
||||
expect(result.toISOString()).toBe("2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user