refactor: 前端视觉重构 — Layout/HeadMenu 骨架、SummaryCards 合并、Card 分组、Drawer 概览重设计
This commit is contained in:
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"tdesign-mcp-server": {
|
||||
"command": "bunx",
|
||||
"args": ["tdesign-mcp-server@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,7 +541,7 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
|
||||
| 语言 | TypeScript 6 | 类型安全 |
|
||||
| UI 库 | TDesign React + tdesign-icons-react | UI 组件与图标 |
|
||||
| 数据层 | TanStack Query (React Query) + React Query Devtools | 服务端状态管理与自动轮询 |
|
||||
| 图表 | Recharts | 拨测趋势折线图与状态环状图 |
|
||||
| 图表 | Recharts | 拨测趋势折线图 |
|
||||
| 路由 | 无(单页面 Dashboard) | 仅需 Drawer/Tab 做页面内导航 |
|
||||
|
||||
**不引入的依赖**:React Router(单页面场景不需要)、状态管理库(TanStack Query 即服务端状态层,组件内用 `useState` 足够)、Vite(已由 Bun 原生 fullstack 替代)
|
||||
@@ -553,17 +553,17 @@ main.tsx
|
||||
└── StrictMode
|
||||
└── ErrorBoundary(React 错误边界)
|
||||
└── QueryClientProvider(TanStack Query 全局挂载)
|
||||
├── App(根组件)
|
||||
│ ├── useDashboard() ─── GET /api/dashboard?window=24h&recentLimit=30(8s 轮询)
|
||||
│ ├── SummaryCards(总览统计卡片)
|
||||
│ └── TargetBoard(目标列表)
|
||||
├── App(根组件,Layout + HeadMenu 骨架)
|
||||
│ ├── useDashboard() ─── GET /api/dashboard?window=24h&recentLimit=30(8s 轮询,dataUpdatedAt 倒计时)
|
||||
│ ├── SummaryCards(单 Card 内嵌居中 Statistic,无 shadow)
|
||||
│ └── TargetBoard(目标列表,Space 24px 间距)
|
||||
│ ├── DashboardResponse.targets
|
||||
│ ├── useMeta() ───── GET /api/meta(应用生命周期内缓存)
|
||||
│ └── TargetGroup[](按 group 字段分组)
|
||||
│ └── TargetGroup[](Card 包裹 PrimaryTable,headerBordered)
|
||||
│ └── PrimaryTable ← createTargetTableColumns(checkerTypes)
|
||||
│ └── TargetDetailDrawer(目标详情抽屉)
|
||||
│ └── TargetDetailDrawer(目标详情抽屉,width=52%)
|
||||
│ └── useTargetDetail() ── 按需发起 metrics + history 查询
|
||||
│ ├── OverviewTab → Statistic + TrendChart + StatusDonut + Descriptions
|
||||
│ ├── OverviewTab → Descriptions(直接展示)+ 4×2 统计卡片 + TrendChart
|
||||
│ └── HistoryTab → PrimaryTable(分页历史记录)
|
||||
└── ReactQueryDevtools(开发工具,仅开发环境)
|
||||
```
|
||||
@@ -679,21 +679,19 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps)
|
||||
|
||||
#### 现有组件清单
|
||||
|
||||
| 组件 | 文件 | 用途 |
|
||||
| -------------------- | ----------------------------------- | ----------------------------------------- |
|
||||
| `App` | `app.tsx` | 根组件,编排全局状态与布局 |
|
||||
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
|
||||
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(全部/正常/异常) |
|
||||
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表 |
|
||||
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组标题 + PrimaryTable |
|
||||
| `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` | 圆形状态指示点(绿/红) |
|
||||
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块) |
|
||||
| `GroupHeader` | `components/GroupHeader.tsx` | 分组标题(名称 + 统计) |
|
||||
| 组件 | 文件 | 用途 |
|
||||
| -------------------- | ----------------------------------- | ----------------------------------------------------------- |
|
||||
| `App` | `app.tsx` | 根组件,Layout + HeadMenu 骨架、刷新倒计时、Skeleton 加载 |
|
||||
| `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI |
|
||||
| `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(单 Card 内嵌居中 Statistic,无 shadow) |
|
||||
| `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表(Space 24px 间距) |
|
||||
| `TargetGroup` | `components/TargetGroup.tsx` | 单个分组 Card(title+actions+headerBordered)+ PrimaryTable |
|
||||
| `TargetDetailDrawer` | `components/TargetDetailDrawer.tsx` | 目标详情抽屉(width=52%)、时间选择器单行布局和 Tab 切换 |
|
||||
| `OverviewTab` | `components/OverviewTab.tsx` | 目标详情概览(Descriptions 直接展示 + 4×2 统计卡片 + 趋势) |
|
||||
| `HistoryTab` | `components/HistoryTab.tsx` | 目标历史记录表格和分页 |
|
||||
| `TrendChart` | `components/TrendChart.tsx` | Recharts 双轴折线图(耗时/可用率) |
|
||||
| `StatusDot` | `components/StatusDot.tsx` | 圆形状态指示点(绿/红) |
|
||||
| `StatusBar` | `components/StatusBar.tsx` | 最近采样状态条(多色块 + Tooltip 提示时间和状态) |
|
||||
|
||||
### 2.5 新增功能开发步骤
|
||||
|
||||
|
||||
@@ -31,11 +31,11 @@ styles.css SHALL 定义 10 级可用率色阶 CSS 自定义属性,使用项目
|
||||
- **THEN** 色阶 SHALL 从红色(0-30%)经橙色(30-60%)过渡到绿色(60-100%)
|
||||
|
||||
### Requirement: 辅助工具类
|
||||
styles.css SHALL 定义前端组件复用的工具类。
|
||||
styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关类。
|
||||
|
||||
#### Scenario: 文本禁用色类
|
||||
- **WHEN** 延迟列无数据需要显示占位符
|
||||
- **THEN** 组件 SHALL 使用 `.text-disabled` 类(color: `--td-text-color-disabled`),不使用内联 style
|
||||
- **THEN** 组件 SHALL 使用 `.text-disabled` 类(color: `--td-text-color-disabled`)
|
||||
|
||||
#### Scenario: 等宽数字类
|
||||
- **WHEN** 数值需要等宽显示
|
||||
@@ -43,26 +43,70 @@ styles.css SHALL 定义前端组件复用的工具类。
|
||||
|
||||
#### Scenario: 延迟色值类
|
||||
- **WHEN** 延迟数值渲染
|
||||
- **THEN** 组件 SHALL 使用 `.latency-ok`(color: `--td-success-color`)、`.latency-warn`(color: `--td-warning-color`)或 `.latency-error`(color: `--td-error-color`)类,不使用内联 style
|
||||
- **THEN** 组件 SHALL 使用 `.latency-ok`、`.latency-warn` 或 `.latency-error` 类
|
||||
|
||||
#### Scenario: 延迟值容器类
|
||||
- **WHEN** 延迟数值需要固定宽度对齐
|
||||
- **THEN** 组件 SHALL 使用 `.latency-value` 类(display: inline-block; min-width: 7ch; white-space: nowrap)
|
||||
|
||||
#### Scenario: 全宽布局类
|
||||
- **WHEN** 组件需要占满父容器宽度
|
||||
- **THEN** 组件 SHALL 使用 `.full-width` 类(width: 100%),不使用内联 style
|
||||
- **THEN** 组件 SHALL 使用 `.full-width` 类(width: 100%)
|
||||
|
||||
#### Scenario: 可点击表格类
|
||||
- **WHEN** PrimaryTable 行支持点击交互
|
||||
- **THEN** 表格 SHALL 使用 `.clickable-table` 类(cursor: pointer),不使用内联 style
|
||||
- **THEN** 表格 SHALL 使用 `.clickable-table` 类(cursor: pointer)
|
||||
|
||||
#### Scenario: Tab 面板内边距类
|
||||
- **WHEN** Drawer 内 Tabs 面板需要内边距
|
||||
- **THEN** TabPanel SHALL 使用 `className="tab-panel-padded"` prop 传入类名,不通过入侵 TDesign 内部类名覆盖
|
||||
- **THEN** TabPanel SHALL 使用 `className="tab-panel-padded"` prop 传入类名
|
||||
|
||||
#### Scenario: 内容区居中类
|
||||
- **WHEN** Dashboard 内容区需要居中且限制最大宽度
|
||||
- **THEN** 内容区 SHALL 使用 `.dashboard-content` 类(max-width: 1400px; margin: 0 auto; padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl))
|
||||
|
||||
#### Scenario: 页面背景色
|
||||
- **WHEN** Dashboard 页面渲染
|
||||
- **THEN** `.dashboard` 类 SHALL 设置 background: var(--td-bg-color-page),min-height: 100vh,width: 100%
|
||||
|
||||
#### Scenario: 品牌标识类
|
||||
- **WHEN** HeadMenu logo 区域渲染品牌名和副标题
|
||||
- **THEN** 品牌 SHALL 使用 `.dashboard-brand` 类(display: inline-flex; align-items: baseline; gap: var(--td-comp-margin-s)),品牌名 SHALL 使用 `.dashboard-logo` 类(font-size: calc(var(--td-font-size-title-large) + 6px); font-weight: 700),副标题 SHALL 使用 `.dashboard-subtitle` 类(font-size: var(--td-font-size-body-medium); color: var(--td-text-color-secondary))
|
||||
|
||||
#### Scenario: 刷新状态类
|
||||
- **WHEN** HeadMenu operations 区域渲染刷新倒计时
|
||||
- **THEN** 容器 SHALL 使用 `.dashboard-refresh-status` 类(display: inline-flex; align-items: center; margin-right: var(--td-comp-margin-xxl))
|
||||
|
||||
#### Scenario: SummaryCard 居中类
|
||||
- **WHEN** SummaryCards 内 Statistic 需要居中
|
||||
- **THEN** Statistic 所在的 Col SHALL 使用 `.summary-stat-col` 类(text-align: center)
|
||||
|
||||
#### Scenario: SummaryCards 行间距类
|
||||
- **WHEN** SummaryCards 容器需要与下方内容保持间距
|
||||
- **THEN** 容器 SHALL 使用 `.summary-cards-row` 类(margin-bottom: var(--td-comp-margin-xl))
|
||||
|
||||
#### Scenario: Drawer 时间控件单行类
|
||||
- **WHEN** Drawer 时间选择器需要单行布局
|
||||
- **THEN** 控件容器 SHALL 使用 `.drawer-time-controls` 类(display: flex; align-items: center; gap: var(--td-comp-margin-m); width: 100%),日期选择器 SHALL 使用 `.drawer-date-range` 类(flex: 1; min-width: 360px)
|
||||
|
||||
#### Scenario: Drawer 时间控件响应式
|
||||
- **WHEN** 视口宽度 ≤ 768px
|
||||
- **THEN** `.drawer-time-controls` SHALL 启用 flex-wrap,`.drawer-date-range` min-width 改为 100%
|
||||
|
||||
#### Scenario: 概览统计卡片类
|
||||
- **WHEN** Drawer 概览统计区渲染
|
||||
- **THEN** 统计卡片 SHALL 使用 `.overview-stat-card` 类(background: var(--td-bg-color-container-hover)),内部项 SHALL 使用 `.overview-stat-item` 类(display: flex; align-items: center; justify-content: space-between),数值 SHALL 使用 `.overview-stat-value` 类(font-size: var(--td-font-size-body-medium); text-align: right)
|
||||
|
||||
### Requirement: 异常行背景类
|
||||
styles.css SHALL 定义 DOWN 行的背景色,使用安全选择器且不使用 `!important`。
|
||||
styles.css SHALL 定义 DOWN 行的背景色和左侧竖线,使用安全选择器且不使用 `!important`。
|
||||
|
||||
#### Scenario: DOWN 行背景色
|
||||
- **WHEN** 表格行标记为 DOWN 状态
|
||||
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得浅红色背景(引用 `--td-error-color-light` token),不使用 `!important`
|
||||
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得浅红色背景
|
||||
|
||||
#### Scenario: DOWN 行左侧竖线
|
||||
- **WHEN** 表格行标记为 DOWN 状态
|
||||
- **THEN** 行 SHALL 通过 `.t-table tr.row-down` 选择器获得 `border-left: 3px solid var(--td-error-color)`
|
||||
|
||||
#### Scenario: DOWN 行 hover 状态
|
||||
- **WHEN** 鼠标悬停在 DOWN 行上
|
||||
|
||||
40
openspec/specs/dashboard-layout/spec.md
Normal file
40
openspec/specs/dashboard-layout/spec.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Purpose
|
||||
|
||||
定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识和刷新倒计时)、内容区域居中与最大宽度、页面背景色。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 页面骨架布局
|
||||
Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶部导航栏和内容区域。
|
||||
|
||||
#### Scenario: Layout 结构
|
||||
- **WHEN** Dashboard 页面渲染
|
||||
- **THEN** 页面 SHALL 使用 TDesign `Layout` 组件包裹 `Layout.Header` 和 `Layout.Content`
|
||||
|
||||
#### Scenario: 顶部导航栏
|
||||
- **WHEN** Dashboard 页面渲染
|
||||
- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染数据刷新倒计时文字
|
||||
|
||||
#### Scenario: 刷新倒计时
|
||||
- **WHEN** Dashboard 数据已成功获取(dataUpdatedAt > 0)
|
||||
- **THEN** HeadMenu operations 区域 SHALL 展示刷新倒计时文本(如"下一次刷新:5秒"),使用 TDesign Typography.Text(theme="secondary"),基于 React Query `dataUpdatedAt` 和轮询间隔常量计算
|
||||
|
||||
#### Scenario: 刷新中状态
|
||||
- **WHEN** Dashboard 正在重新获取数据(isFetching=true 且 isLoading=false)
|
||||
- **THEN** 刷新倒计时文本 SHALL 展示为"刷新中..."
|
||||
|
||||
#### Scenario: 首次加载状态
|
||||
- **WHEN** Dashboard 尚未获取过数据(dataUpdatedAt = 0)
|
||||
- **THEN** 刷新倒计时文本 SHALL 展示为"等待首次刷新"
|
||||
|
||||
#### Scenario: 刷新倒计时位置
|
||||
- **WHEN** HeadMenu 渲染
|
||||
- **THEN** operations 区域 SHALL 使用右侧 margin 向内收缩,避免紧贴浏览器右边缘
|
||||
|
||||
#### Scenario: 内容区域居中
|
||||
- **WHEN** Dashboard 内容区渲染
|
||||
- **THEN** `Layout.Content` 内部 SHALL 使用 CSS 类限制最大宽度(max-width: 1400px)并水平居中
|
||||
|
||||
#### Scenario: 页面背景色
|
||||
- **WHEN** Dashboard 页面渲染
|
||||
- **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于浅灰背景之上
|
||||
@@ -1,6 +1,6 @@
|
||||
## Purpose
|
||||
|
||||
定义拨测系统前端 Dashboard 页面:总览统计卡片(含数据新鲜度)、Dashboard 数据查询、页面标题、加载和错误状态处理。分组表格布局见 `target-table`,目标详情 Drawer 见 `target-detail-drawer`,数据轮询和缓存见 `tanstack-query-data-layer`。
|
||||
定义拨测系统前端 Dashboard 页面:总览统计卡片、Dashboard 数据查询、加载和错误状态处理。页面骨架布局见 `dashboard-layout`,分组表格布局见 `target-table`,目标详情 Drawer 见 `target-detail-drawer`,数据轮询和缓存见 `tanstack-query-data-layer`。
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -20,41 +20,26 @@ Dashboard SHALL 通过 `GET /api/dashboard` 获取首屏总览统计和目标列
|
||||
- **THEN** 前端 SHALL 继续通过 `GET /api/meta` 独立查询 checkerTypes
|
||||
|
||||
### Requirement: 总览统计卡片
|
||||
Dashboard SHALL 在页面顶部使用 TDesign Statistic 组件展示总览统计,包含总目标数、正常数、异常数和窗口异常事件数,并展示数据新鲜度。
|
||||
Dashboard SHALL 在页面顶部使用单个 TDesign Card 组件内嵌一行居中的 Statistic 展示总览统计,包含总目标数、正常数、异常数和窗口异常事件数。
|
||||
|
||||
#### Scenario: 展示统计卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面顶部 SHALL 使用 TDesign Row/Col 布局展示 4 个 TDesign Card + Statistic 组合:全部目标数(color=blue)、正常目标数(color=green)、异常目标数(color=red)、24h 异常事件数(color=orange)
|
||||
- **THEN** 页面顶部 SHALL 使用单个 TDesign Card(无 shadow、无 bordered)内嵌 TDesign Row/Col 布局展示 4 个居中的 Statistic:全部目标数(color=blue)、正常目标数(color=green)、异常目标数(color=red)、24h 异常事件数(color=orange)
|
||||
|
||||
#### Scenario: 指标居中显示
|
||||
- **WHEN** SummaryCards 渲染
|
||||
- **THEN** 每个 Statistic 所在的 Col SHALL 使用 `.summary-stat-col` 类实现标题和数字居中对齐
|
||||
|
||||
#### Scenario: 异常事件数据来源
|
||||
- **WHEN** SummaryCards 渲染 24h 异常事件数
|
||||
- **THEN** 该数值 SHALL 使用 DashboardResponse.summary.incidents 字段,标题 SHALL 基于当前 window 展示为"24h 异常事件数"
|
||||
|
||||
#### Scenario: 展示数据新鲜度
|
||||
- **WHEN** Summary 数据包含 lastCheckTime
|
||||
- **THEN** 统计卡片行底部 SHALL 展示相对时间文本(如"最后更新: 3秒前"),使用 TDesign Typography.Text(theme="secondary")
|
||||
|
||||
#### Scenario: 数据新鲜度警告
|
||||
- **WHEN** lastCheckTime 距当前时间超过 60 秒
|
||||
- **THEN** 相对时间文本 SHALL 使用警告色(--td-warning-color)
|
||||
|
||||
#### Scenario: 无检查时间
|
||||
- **WHEN** Summary 数据 lastCheckTime 为 null
|
||||
- **THEN** 数据新鲜度 SHALL 展示为"尚无检查数据"或等价占位文本
|
||||
|
||||
### Requirement: 页面标题
|
||||
Dashboard 页面 SHALL 使用 TDesign Typography 组件渲染标题和副标题。
|
||||
|
||||
#### Scenario: 页面标题渲染
|
||||
- **WHEN** Dashboard 页面渲染
|
||||
- **THEN** 页面标题 SHALL 使用 TDesign Typography.Title 组件(level="h1")渲染"DiAL",副标题 SHALL 使用 Typography.Text 组件(theme="secondary")渲染"统一拨测平台"
|
||||
|
||||
### Requirement: 页面加载与错误状态
|
||||
Dashboard SHALL 使用 TDesign 组件正确处理加载状态和 API 错误。
|
||||
Dashboard SHALL 使用 TDesign Skeleton 组件处理首次加载状态,使用 Alert 处理错误。
|
||||
|
||||
#### Scenario: 首次加载
|
||||
- **WHEN** 页面首次加载且数据尚未返回
|
||||
- **THEN** 表格 SHALL 显示 TDesign Loading 加载状态
|
||||
- **THEN** 页面 SHALL 使用 TDesign Skeleton 组件(animation="gradient")展示页面骨架,模拟 Summary 区域和 Table 区域的大致结构
|
||||
|
||||
#### Scenario: API 请求失败
|
||||
- **WHEN** 前端 API 请求失败
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Purpose
|
||||
|
||||
定义目标详情 Drawer:时间范围筛选(TDesign RadioGroup + DateRangePicker,含快捷按钮联动概览和记录面板)、Tabs 组织概览/记录两个面板、Metrics 数据查询 Hook、多维度统计图表(2×4 布局)和分页检查结果列表。
|
||||
定义目标详情 Drawer:时间范围筛选(TDesign RadioGroup + DateRangePicker 单行布局,含快捷按钮联动概览和记录面板)、Tabs 组织概览/记录两个面板、Metrics 数据查询 Hook、多维度统计图表(4×2 Card 布局)和分页检查结果列表。
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -9,7 +9,7 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示
|
||||
|
||||
#### Scenario: 打开 Drawer
|
||||
- **WHEN** 用户点击某个目标表格行
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right"),宽度为视口 60%
|
||||
- **THEN** 系统 SHALL 从右侧滑出 Drawer(placement="right"),宽度为 52%
|
||||
|
||||
#### Scenario: Drawer 标题栏
|
||||
- **WHEN** Drawer 渲染
|
||||
@@ -36,15 +36,11 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer,展示
|
||||
- **THEN** 时间选择器、Tabs 等区块之间的间距 SHALL 通过 TDesign Space 组件(direction="vertical", size={16})统一管理,不使用内联 style 的 marginBottom
|
||||
|
||||
### Requirement: 概览面板组件化
|
||||
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,展示多维度统计、趋势图、状态分布和基本信息。
|
||||
概览 Tab SHALL 作为独立组件 `OverviewTab` 实现,展示基本信息、多维度统计(左右布局卡片)和趋势图。不再包含状态分布环形图。
|
||||
|
||||
#### Scenario: OverviewTab 组件职责
|
||||
- **WHEN** 概览 Tab 渲染
|
||||
- **THEN** `OverviewTab` 组件 SHALL 负责多维度统计卡片(2×4 布局)、趋势图(延迟范围面积图+异常标记点)、状态分布环形图和基本信息的渲染
|
||||
|
||||
#### Scenario: 统计计算不再使用 computeTrendStats
|
||||
- **WHEN** OverviewTab 需要 totalChecks、upChecks、downChecks
|
||||
- **THEN** SHALL 直接使用 metricsData.stats 中的 totalChecks、upChecks、downChecks 字段,`computeTrendStats` 工具函数 SHALL 被删除
|
||||
- **THEN** `OverviewTab` 组件 SHALL 负责基本信息(直接展示 Descriptions)、多维度统计卡片(4×2 左右布局)和趋势图的渲染
|
||||
|
||||
#### Scenario: OverviewTab props
|
||||
- **WHEN** OverviewTab 渲染
|
||||
@@ -76,13 +72,6 @@ TrendChart 组件 SHALL 仅接收数据 props,不处理 loading 状态。
|
||||
- **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 支持可配置的格数。
|
||||
|
||||
@@ -118,7 +107,7 @@ StatusBar 组件 SHALL 支持可配置的格数。
|
||||
- **THEN** 系统 MAY 清理 metrics 和 history 查询缓存,避免旧目标数据残留
|
||||
|
||||
### Requirement: 时间范围选择器
|
||||
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。时间选择器 SHALL 分两行显示:第一行为快捷按钮,第二行为日期时间范围选择器。
|
||||
Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录两个面板的数据。
|
||||
|
||||
#### Scenario: 快捷时间按钮
|
||||
- **WHEN** Drawer 渲染
|
||||
@@ -146,7 +135,7 @@ Drawer SHALL 在 Tabs 外层提供时间范围选择器,影响概览和记录
|
||||
|
||||
#### Scenario: DateRangePicker 全宽显示
|
||||
- **WHEN** Drawer 渲染
|
||||
- **THEN** DateRangePicker SHALL 通过 CSS 类 `.full-width` 占满时间选择区第二行的宽度,不使用内联 style 的 width: 100%
|
||||
- **THEN** DateRangePicker SHALL 通过 CSS 类 `.full-width` 占满时间选择区的宽度,不使用内联 style 的 width: 100%
|
||||
|
||||
#### Scenario: 默认时间范围
|
||||
- **WHEN** Drawer 打开
|
||||
@@ -168,31 +157,31 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。TabP
|
||||
- **THEN** TabPanel SHALL 通过 `className` prop 传入自定义类名(`tab-panel-padded`)控制内边距,不通过入侵 TDesign 内部类名(`.t-tab-panel`)覆盖
|
||||
|
||||
### Requirement: 概览面板
|
||||
概览 Tab SHALL 按区域展示多维度统计、趋势图、状态分布和基本信息。
|
||||
概览 Tab SHALL 按区域展示基本信息、多维度统计和趋势图。
|
||||
|
||||
#### Scenario: 区域排列顺序
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 按以下顺序展示区域:统计 → 趋势 → 状态分布 → 基本信息,每个区域前 SHALL 显示 TDesign Divider(align="left")作为小标题
|
||||
- **THEN** 面板 SHALL 按以下顺序展示区域:基本信息 → 统计 → 趋势,每个区域前 SHALL 显示 TDesign Divider(align="left")作为小标题
|
||||
|
||||
#### Scenario: 统计区多维度布局
|
||||
#### Scenario: 基本信息直接展示
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 在"统计"区域使用 2 行 × 4 列的 TDesign Row/Col + Statistic 布局:第一行为可用率(suffix="%")、平均延迟(suffix="ms")、P95 延迟(suffix="ms")、检查总数;第二行为 MTTR(动态单位)、最长故障(动态单位)、故障次数(suffix="次")、连续正常(suffix="次",固定标题"连续正常",当目标当前处于异常状态时值为 0)
|
||||
- **THEN** 面板 SHALL 在"基本信息"区域直接使用 TDesign Descriptions 组件展示配置信息(不折叠)
|
||||
|
||||
#### Scenario: P99 暂不展示
|
||||
- **WHEN** metricsData.stats 包含 p99DurationMs
|
||||
- **THEN** 当前 2×4 统计区 SHALL 不展示 P99 延迟
|
||||
#### Scenario: 基本信息内容
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情
|
||||
|
||||
#### Scenario: MTTR 和最长故障动态单位
|
||||
- **WHEN** MTTR 或最长故障值小于 60000ms
|
||||
- **THEN** SHALL 以秒为单位展示(suffix="秒")
|
||||
- **WHEN** 值大于等于 60000ms 且小于 3600000ms
|
||||
- **THEN** SHALL 以分钟为单位展示(suffix="分钟")
|
||||
- **WHEN** 值大于等于 3600000ms
|
||||
- **THEN** SHALL 以小时为单位展示(suffix="小时")
|
||||
#### Scenario: 统计区左右布局卡片
|
||||
- **WHEN** 概览面板渲染且有统计数据
|
||||
- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 Card 包裹,Card 内标题左对齐、数值右对齐,数值使用普通文本字号
|
||||
|
||||
#### Scenario: 统计区数据来源
|
||||
- **WHEN** 统计区渲染
|
||||
- **THEN** 第一行和第二行数据 SHALL 来自 metricsData.stats
|
||||
#### Scenario: 统计区内容
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 统计区 SHALL 展示:可用率(suffix="%")、平均延迟(suffix="ms")、P95 延迟(suffix="ms")、检查总数、MTTR(动态单位)、最长故障(动态单位)、故障次数(suffix="次")、连续正常(suffix="次")
|
||||
|
||||
#### Scenario: 趋势图
|
||||
- **WHEN** 概览面板渲染且 metricsData.trend 可用
|
||||
- **THEN** 面板 SHALL 在"趋势"区域展示 TrendChart 组件
|
||||
|
||||
#### Scenario: 统计区加载状态
|
||||
- **WHEN** metricsData 正在加载
|
||||
@@ -200,39 +189,25 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。TabP
|
||||
|
||||
#### Scenario: 统计区无数据
|
||||
- **WHEN** metricsData 为 null 且未处于加载状态
|
||||
- **THEN** 统计区 SHALL 展示占位状态,不从其他数据源反推统计值
|
||||
|
||||
#### Scenario: 延迟类指标无数据
|
||||
- **WHEN** metricsData.stats 中 avgDurationMs 或 p95DurationMs 为 null
|
||||
- **THEN** 对应 Statistic SHALL 展示值为 0 且不带单位后缀(TDesign Statistic value 仅接受 number,无数据时通过缺省 suffix 区分)
|
||||
|
||||
#### Scenario: 趋势图延迟范围面积
|
||||
- **WHEN** 概览面板渲染且 metricsData.trend 可用
|
||||
- **THEN** 趋势图 SHALL 使用 recharts Area 组件渲染 minDurationMs 到 maxDurationMs 的延迟范围(半透明品牌色填充),叠加 avgDurationMs 实线
|
||||
|
||||
#### Scenario: 趋势图时间轴标签本地化
|
||||
- **WHEN** 趋势图渲染 X 轴标签
|
||||
- **THEN** 前端 SHALL 使用 `toLocaleTimeString` 或等价方法将 UTC `bucketStart` 转换为本地时间标签(如 "08:00"),不直接展示 UTC 时间字符串
|
||||
|
||||
#### Scenario: 趋势图异常标记点
|
||||
- **WHEN** metricsData.trend 中某小时的 availability < 100
|
||||
- **THEN** 趋势图 SHALL 在 avgDurationMs 线上该时间点渲染红色圆点(fill: var(--td-error-color)),使用 recharts Line 的 dot 回调函数实现;图表 SHALL 仅保留左侧 Y 轴(ms),移除右侧 Y 轴(%)和 availability 折线
|
||||
- **THEN** 统计区 SHALL 展示占位状态
|
||||
|
||||
#### Scenario: 趋势数据加载中
|
||||
- **WHEN** metricsData 正在加载
|
||||
- **THEN** "趋势"区域 SHALL 显示 TDesign Skeleton 加载占位
|
||||
|
||||
#### Scenario: 状态分布环形图
|
||||
- **WHEN** 概览面板渲染且 metricsData 可用
|
||||
- **THEN** 面板 SHALL 在"状态分布"区域展示 recharts 环形图(StatusDonut),使用 metricsData.stats.upChecks 和 metricsData.stats.downChecks 作为数据源,外圈显示 UP/DOWN 比例,中间显示可用率百分比
|
||||
### Requirement: Drawer 宽度
|
||||
Drawer 宽度 SHALL 设置为 52%。
|
||||
|
||||
#### Scenario: 状态分布加载状态
|
||||
- **WHEN** metricsData 正在加载
|
||||
- **THEN** 状态分布区域 SHALL 显示 TDesign Skeleton 加载占位
|
||||
#### Scenario: Drawer 宽度
|
||||
- **WHEN** Drawer 打开
|
||||
- **THEN** Drawer size SHALL 为 "52%"
|
||||
|
||||
#### Scenario: 元信息展示
|
||||
- **WHEN** 概览面板渲染
|
||||
- **THEN** 面板 SHALL 在"基本信息"区域使用 TDesign Descriptions 组件展示目标元信息:目标地址、检查间隔、最新检查时间、状态详情
|
||||
### Requirement: 时间选择器单行布局
|
||||
Drawer 顶部的时间范围快捷按钮和日期范围选择器 SHALL 在同一行展示。
|
||||
|
||||
#### Scenario: 单行布局
|
||||
- **WHEN** Drawer 渲染时间选择区域
|
||||
- **THEN** RadioGroup 和 DateRangePicker SHALL 使用 flex 布局在同一行水平排列
|
||||
|
||||
### Requirement: 记录面板
|
||||
记录 Tab SHALL 展示分页检查结果列表,使用 TDesign PrimaryTable。
|
||||
|
||||
@@ -5,38 +5,42 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: 分组表格展示
|
||||
Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立的 TDesign PrimaryTable,分组间使用 TDesign Space 垂直排列。
|
||||
Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Card 包裹独立的 PrimaryTable,分组间使用 TDesign Space 垂直排列。
|
||||
|
||||
#### Scenario: 按分组渲染独立表格
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组,每个分组包含带统计的分组标题和一个独立 PrimaryTable
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组,每个分组使用 TDesign Card 组件包裹,Card 内包含一个 PrimaryTable
|
||||
|
||||
#### Scenario: 分组 Card 标题
|
||||
- **WHEN** 页面渲染某个分组
|
||||
- **THEN** Card 的 `title` prop SHALL 渲染分组名称("default" 显示为 "默认分组"),Card 的 `actions` prop SHALL 渲染统计 Tag:正常数(theme=success, variant=light)和异常数(theme=danger, variant=light)
|
||||
|
||||
#### Scenario: 分组 Card 样式
|
||||
- **WHEN** 页面渲染分组 Card
|
||||
- **THEN** Card SHALL 设置 `headerBordered` 在标题和表格之间显示分割线
|
||||
|
||||
#### Scenario: 分组顺序
|
||||
- **WHEN** 页面渲染多个分组
|
||||
- **THEN** "default" 分组 SHALL 排在最上面,其余分组按 YAML 配置中首次出现的顺序排列
|
||||
|
||||
#### Scenario: 分组标题统计标签
|
||||
- **WHEN** 页面渲染某个分组的标题
|
||||
- **THEN** 标题 SHALL 使用 TDesign Tag 组件显示分组名称和三个统计标签:总数(theme=primary, variant=light)、正常数(theme=success, variant=light)、异常数(theme=danger, variant=light)
|
||||
|
||||
#### Scenario: "default" 分组显示名称
|
||||
- **WHEN** 分组名称为 "default"
|
||||
- **THEN** 分组标题 SHALL 显示 "默认分组"
|
||||
- **THEN** Card title SHALL 显示 "默认分组"
|
||||
|
||||
#### Scenario: Dashboard 容器占满宽度
|
||||
#### Scenario: Dashboard 容器最大宽度
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** Dashboard 容器 SHALL 占满浏览器宽度,不设置 max-width 限制
|
||||
- **THEN** Dashboard 内容区 SHALL 设置 max-width: 1400px 并水平居中
|
||||
|
||||
#### Scenario: 分组间统一间距
|
||||
- **WHEN** 页面渲染多个分组
|
||||
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件(direction=vertical, size=32px)统一间距
|
||||
- **THEN** 分组之间 SHALL 使用 TDesign Space 组件(direction=vertical, size=24)统一间距
|
||||
|
||||
### Requirement: 表格列定义
|
||||
每个分组的 PrimaryTable SHALL 包含状态、名称、类型、可用率、最近状态条、延迟、间隔 7 列,不含分组列(同组内冗余)。列渲染不使用内联 style。列定义 SHALL 通过工厂函数动态生成。
|
||||
每个分组的 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
|
||||
- **THEN** 状态列 SHALL 使用 StatusDot 组件渲染,标题显示"#",宽度 60px,fixed="left",居中对齐,支持筛选(UP/DOWN/全部)
|
||||
|
||||
#### Scenario: 名称列
|
||||
- **WHEN** 表格渲染
|
||||
@@ -52,43 +56,23 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组渲染一个独立
|
||||
|
||||
#### Scenario: 可用率列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 可用率列标题 SHALL 展示为"可用率(24h)"(基于 Dashboard 默认 window=24h),使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色通过 CSS 自定义属性 `--avail-N`(基于项目自定义色值)控制,每 10% 一档,label 显示百分比数值,支持排序(升序优先,最差排最前)。前端 SHALL 使用 DashboardResponse.targets[].stats.availability 字段作为数据来源。color-threshold 函数 SHALL 返回 CSS 自定义属性引用而非硬编码色值
|
||||
- **THEN** 可用率列标题 SHALL 展示为"可用率(24h)",使用 TDesign Progress 组件(theme=line, size=small)渲染,颜色通过 CSS 自定义属性 `--avail-N` 控制,支持排序
|
||||
|
||||
#### Scenario: 最近状态列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px。StatusBar SHALL 通过 CSS 类(`.status-bar-block--up` / `.status-bar-block--down` / `.status-bar-block--empty`)控制色块颜色,不使用内联 style
|
||||
- **THEN** 最近状态列 SHALL 使用 StatusBar 组件渲染采样色块,宽度 220px
|
||||
|
||||
#### Scenario: 连续状态列渲染
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 表格 SHALL 在「最近状态」列之后、「延迟」列之前展示「连续状态」列,标题为"连续",宽度 100px
|
||||
|
||||
#### Scenario: 连续正常展示
|
||||
- **WHEN** 目标 currentStreak 为 `{ up: true, count: N }`
|
||||
- **THEN** 列 SHALL 使用 TDesign Tag 组件(theme=success, variant=light, size=small)展示 "▲ N次"
|
||||
|
||||
#### Scenario: 连续异常展示
|
||||
- **WHEN** 目标 currentStreak 为 `{ up: false, count: N }`
|
||||
- **THEN** 列 SHALL 使用 TDesign Tag 组件(theme=danger, variant=light, size=small)展示 "▼ N次"
|
||||
|
||||
#### Scenario: 连续状态数据来源
|
||||
- **WHEN** 表格需要渲染连续状态
|
||||
- **THEN** 前端 SHALL 使用 DashboardResponse.targets[].currentStreak 字段,不在表格列中自行遍历 recentSamples 计算核心指标
|
||||
|
||||
#### Scenario: 超过样本上限
|
||||
- **WHEN** currentStreak.capped 为 true
|
||||
- **THEN** 列 SHALL 展示 "▲ N+" 或 "▼ N+"
|
||||
|
||||
#### Scenario: 无样本数据
|
||||
- **WHEN** 目标 currentStreak 为 null
|
||||
- **THEN** 列 SHALL 展示 "-"
|
||||
- **THEN** 表格 SHALL 在「最近状态」列之后、「延迟」列之前展示「连续状态」列,标题为"连续(次)",宽度 88px,Tag 内显示方向箭头和数字(capped 时追加"+")
|
||||
|
||||
#### Scenario: 延迟列
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐。颜色 SHALL 通过 CSS 类实现:≤100ms 使用 `.latency-ok`、100-500ms 使用 `.latency-warn`、>500ms 使用 `.latency-error`。无数据 SHALL 使用 `.text-disabled` 类显示 "-",数值 SHALL 使用 `.tabular-nums` 类等宽显示。不使用内联 style
|
||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ms"
|
||||
|
||||
#### Scenario: 间隔列
|
||||
#### Scenario: 间隔列移除
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 间隔列 SHALL 显示检查间隔(如 "5s"、"30s"),居中对齐,宽度 72px
|
||||
- **THEN** 表格 SHALL 不包含"间隔"列(间隔信息移入 Drawer 基本信息区域)
|
||||
|
||||
### Requirement: 列定义工厂函数
|
||||
列定义 SHALL 通过工厂函数生成,接收动态参数。
|
||||
@@ -124,15 +108,19 @@ TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态
|
||||
- **THEN** 每个分组表格 SHALL 默认按状态降序排列,DOWN 目标排在同组最前面
|
||||
|
||||
### Requirement: DOWN 行视觉强化
|
||||
表格中状态为 DOWN 的行 SHALL 具有视觉区分,使用安全 CSS 选择器实现。
|
||||
表格中状态为 DOWN 的行 SHALL 具有视觉区分,包含背景色和左侧竖线。
|
||||
|
||||
#### Scenario: DOWN 行背景色
|
||||
- **WHEN** 目标最近一次检查 matched=false
|
||||
- **THEN** 该行 SHALL 通过 `.t-table tr.row-down` CSS 选择器获得浅红色背景(`--td-error-color-light`),不使用 `!important`
|
||||
- **THEN** 该行 SHALL 通过 CSS 选择器获得浅红色背景
|
||||
|
||||
#### Scenario: DOWN 行左侧竖线
|
||||
- **WHEN** 目标最近一次检查 matched=false
|
||||
- **THEN** 该行 SHALL 通过 CSS 选择器获得左侧 3px 红色竖线(border-left: 3px solid var(--td-error-color))
|
||||
|
||||
#### Scenario: DOWN 行 hover 状态
|
||||
- **WHEN** 鼠标悬停在 DOWN 行上
|
||||
- **THEN** 行背景 SHALL 通过 `.t-table--hoverable tbody tr.row-down:hover` 选择器显示 hover 状态色,与正常行 hover 效果协调
|
||||
- **THEN** 行背景 SHALL 显示 hover 状态色,与正常行 hover 效果协调
|
||||
|
||||
### Requirement: 行点击交互
|
||||
表格行 SHALL 支持点击打开目标详情 Drawer。
|
||||
@@ -150,11 +138,26 @@ TargetGroup 组件 SHALL 通过 prop 接收列定义,不再直接导入静态
|
||||
- **THEN** cursor SHALL 显示为 pointer
|
||||
|
||||
### Requirement: 表格外观
|
||||
表格 SHALL 使用 TDesign PrimaryTable 统一外观。
|
||||
表格 SHALL 使用 TDesign PrimaryTable 统一外观,不设置 bordered(由外层 Card 提供边界)。
|
||||
|
||||
#### Scenario: 表格样式
|
||||
- **WHEN** 表格渲染
|
||||
- **THEN** 表格 SHALL 设置 size="small"、stripe、hover、bordered
|
||||
- **THEN** 表格 SHALL 设置 size="small"、stripe、hover,不设置 bordered
|
||||
|
||||
### Requirement: StatusBar Tooltip 交互
|
||||
StatusBar 色块 SHALL 在 hover 时通过 TDesign Tooltip 展示时间和状态信息。组件 props 类型 SHALL 使用完整的 `RecentSample` 类型(包含 timestamp 字段)而非简化的 `{ up: boolean }`。
|
||||
|
||||
#### Scenario: StatusBar props 类型变更
|
||||
- **WHEN** StatusBar 组件接收 samples 数据
|
||||
- **THEN** 组件 SHALL 接收 `Array<RecentSample>` 类型(包含 timestamp、durationMs、up 字段),而非简化的 `Array<{ up: boolean }>` 类型
|
||||
|
||||
#### Scenario: 有数据色块 Tooltip
|
||||
- **WHEN** 鼠标悬停在有数据的色块上
|
||||
- **THEN** 色块 SHALL 通过 TDesign Tooltip(placement="top")展示该采样点的时间(使用 formatRelativeTime 格式化)和状态(正常/异常)
|
||||
|
||||
#### Scenario: 空色块无 Tooltip
|
||||
- **WHEN** 鼠标悬停在空色块(empty)上
|
||||
- **THEN** 色块 SHALL 不显示 Tooltip
|
||||
|
||||
### Requirement: 列定义复用
|
||||
所有分组的表格 SHALL 共享同一套列定义。
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { Alert, Loading, Typography } from "tdesign-react";
|
||||
import type { SkeletonProps } from "tdesign-react";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Layout, Menu, Skeleton, Typography } from "tdesign-react";
|
||||
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||
import { useDashboard } from "./hooks/use-queries";
|
||||
import { DASHBOARD_REFRESH_INTERVAL_MS, useDashboard } from "./hooks/use-queries";
|
||||
import { useTargetDetail } from "./hooks/use-target-detail";
|
||||
|
||||
const { Content, Header } = Layout;
|
||||
const DASHBOARD_SKELETON_ROW_COL: SkeletonProps["rowCol"] = [
|
||||
[{ height: "112px", type: "rect", width: "100%" }],
|
||||
[{ height: "56px", type: "rect", width: "100%" }],
|
||||
[{ height: "320px", type: "rect", width: "100%" }],
|
||||
];
|
||||
|
||||
export function App() {
|
||||
const { data: dashboard, error: dashboardError, isLoading: dashboardLoading } = useDashboard();
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const {
|
||||
data: dashboard,
|
||||
dataUpdatedAt: dashboardUpdatedAt,
|
||||
error: dashboardError,
|
||||
isFetching: dashboardFetching,
|
||||
isLoading: dashboardLoading,
|
||||
} = useDashboard();
|
||||
const {
|
||||
closeDrawer,
|
||||
handlePageChange,
|
||||
@@ -21,25 +38,55 @@ export function App() {
|
||||
timeFrom,
|
||||
timeTo,
|
||||
} = useTargetDetail();
|
||||
const nextRefreshSeconds =
|
||||
dashboardUpdatedAt > 0
|
||||
? Math.max(0, Math.ceil((dashboardUpdatedAt + DASHBOARD_REFRESH_INTERVAL_MS - now.getTime()) / 1000))
|
||||
: null;
|
||||
const refreshText =
|
||||
dashboardUpdatedAt > 0
|
||||
? dashboardFetching && !dashboardLoading
|
||||
? "刷新中..."
|
||||
: `下一次刷新:${nextRefreshSeconds}秒`
|
||||
: "等待首次刷新";
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<Typography.Title level="h1">DiAL</Typography.Title>
|
||||
<Typography.Text theme="secondary">统一拨测平台</Typography.Text>
|
||||
</header>
|
||||
|
||||
{dashboardError && <Alert closeBtn message={`请求失败: ${dashboardError.message}`} theme="error" />}
|
||||
|
||||
{dashboardLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCards summary={dashboard?.summary ?? null} />
|
||||
<TargetBoard onTargetClick={openDrawer} targets={dashboard?.targets ?? []} />
|
||||
</>
|
||||
)}
|
||||
<Layout className="dashboard">
|
||||
<Header>
|
||||
<Menu.HeadMenu
|
||||
logo={
|
||||
<span className="dashboard-brand">
|
||||
<span className="dashboard-logo">DiAL</span>
|
||||
<span className="dashboard-subtitle">统一拨测平台</span>
|
||||
</span>
|
||||
}
|
||||
operations={
|
||||
<span className="dashboard-refresh-status">
|
||||
<Typography.Text className="dashboard-refresh-text" theme="secondary">
|
||||
{refreshText}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Header>
|
||||
<Content>
|
||||
<div className="dashboard-content">
|
||||
{dashboardError && <Alert closeBtn message={`请求失败: ${dashboardError.message}`} theme="error" />}
|
||||
|
||||
{dashboardLoading ? (
|
||||
<Skeleton animation="gradient" rowCol={DASHBOARD_SKELETON_ROW_COL} />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCards summary={dashboard?.summary ?? null} />
|
||||
<TargetBoard onTargetClick={openDrawer} targets={dashboard?.targets ?? []} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Content>
|
||||
<TargetDetailDrawer
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
@@ -53,6 +100,6 @@ export function App() {
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
/>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Tag, Typography } from "tdesign-react";
|
||||
|
||||
interface GroupHeaderProps {
|
||||
down: number;
|
||||
name: string;
|
||||
total: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
export function GroupHeader({ down, name, total, up }: GroupHeaderProps) {
|
||||
const displayName = name === "default" ? "默认分组" : name;
|
||||
|
||||
return (
|
||||
<div className="group-header">
|
||||
<Typography.Title level="h4">{displayName}</Typography.Title>
|
||||
<Tag theme="primary" title="总数" variant="light">
|
||||
{total}
|
||||
</Tag>
|
||||
<Tag theme="success" title="正常" variant="light">
|
||||
{up}
|
||||
</Tag>
|
||||
<Tag theme="danger" title="异常" variant="light">
|
||||
{down}
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic } from "tdesign-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
|
||||
|
||||
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
||||
|
||||
import { formatDurationUnit } from "../utils/time";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface OverviewStatItemProps {
|
||||
color?: string;
|
||||
suffix?: ReactNode;
|
||||
title: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface OverviewTabProps {
|
||||
metricsData: null | TargetMetricsResponse;
|
||||
metricsLoading: boolean;
|
||||
@@ -20,70 +28,6 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
||||
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Divider align="left">统计</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : stats ? (
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="%" title="可用率" value={stats.availability} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic
|
||||
suffix={stats.avgDurationMs === null ? "" : "ms"}
|
||||
title="平均延迟"
|
||||
value={stats.avgDurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic
|
||||
suffix={stats.p95DurationMs === null ? "" : "ms"}
|
||||
title="P95 延迟"
|
||||
value={stats.p95DurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="blue" title="检查总数" value={stats.totalChecks} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={3}>
|
||||
<Statistic suffix={mttr.suffix} title="MTTR" value={mttr.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic suffix={longestOutage.suffix} title="最长故障" value={longestOutage.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Statistic color="green" suffix="次" title="连续正常" value={currentUpStreak} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
) : (
|
||||
<div className="trend-empty">暂无指标数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : metricsData ? (
|
||||
<TrendChart data={metricsData.trend} />
|
||||
) : (
|
||||
<div className="trend-empty">暂无趋势数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">状态分布</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : stats ? (
|
||||
<StatusDonut down={stats.downChecks} up={stats.upChecks} />
|
||||
) : (
|
||||
<div className="trend-empty">暂无状态数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">基本信息</Divider>
|
||||
<Descriptions
|
||||
items={[
|
||||
@@ -96,6 +40,68 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider align="left">统计</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : stats ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem color="green" suffix="%" title="可用率" value={stats.availability} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem
|
||||
suffix={stats.avgDurationMs === null ? "" : "ms"}
|
||||
title="平均延迟"
|
||||
value={stats.avgDurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem
|
||||
suffix={stats.p95DurationMs === null ? "" : "ms"}
|
||||
title="P95 延迟"
|
||||
value={stats.p95DurationMs ?? 0}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem color="blue" title="检查总数" value={stats.totalChecks} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem suffix={mttr.suffix} title="MTTR" value={mttr.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem suffix={longestOutage.suffix} title="最长故障" value={longestOutage.value} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem color="red" suffix="次" title="故障次数" value={stats.incidentCount} />
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<OverviewStatItem color="green" suffix="次" title="连续正常" value={currentUpStreak} />
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<div className="trend-empty">暂无指标数据</div>
|
||||
)}
|
||||
|
||||
<Divider align="left">趋势</Divider>
|
||||
{metricsLoading ? (
|
||||
<Skeleton animation="gradient" />
|
||||
) : metricsData ? (
|
||||
<TrendChart data={metricsData.trend} />
|
||||
) : (
|
||||
<div className="trend-empty">暂无趋势数据</div>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) {
|
||||
return (
|
||||
<Card bordered={false} className="overview-stat-card" size="small">
|
||||
<div className="overview-stat-item">
|
||||
<Typography.Text theme="secondary">{title}</Typography.Text>
|
||||
<Statistic className="overview-stat-value" color={color} suffix={suffix} value={value} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Tooltip } from "tdesign-react";
|
||||
|
||||
import type { RecentSample } from "../../shared/api";
|
||||
|
||||
import { formatRelativeTime } from "../utils/time";
|
||||
|
||||
interface StatusBarProps {
|
||||
maxSlots?: number;
|
||||
samples: Array<{ up: boolean }>;
|
||||
samples: RecentSample[];
|
||||
}
|
||||
|
||||
export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
|
||||
@@ -9,10 +15,13 @@ export function StatusBar({ maxSlots = 30, samples }: StatusBarProps) {
|
||||
const sample = samples[i];
|
||||
if (sample) {
|
||||
blocks.push(
|
||||
<span
|
||||
className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`}
|
||||
<Tooltip
|
||||
content={`${formatRelativeTime(sample.timestamp)},${sample.up ? "正常" : "异常"}`}
|
||||
key={i}
|
||||
/>,
|
||||
placement="top"
|
||||
>
|
||||
<span className={`status-bar-block ${sample.up ? "status-bar-block--up" : "status-bar-block--down"}`} />
|
||||
</Tooltip>,
|
||||
);
|
||||
} else {
|
||||
blocks.push(<span className="status-bar-block status-bar-block--empty" key={i} />);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface StatusDonutProps {
|
||||
down: number;
|
||||
up: number;
|
||||
}
|
||||
|
||||
const UP_COLOR = "var(--td-success-color)";
|
||||
const DOWN_COLOR = "var(--td-error-color)";
|
||||
const EMPTY_COLOR = "var(--td-bg-color-component-disabled)";
|
||||
|
||||
export function StatusDonut({ down, up }: StatusDonutProps) {
|
||||
const total = up + down;
|
||||
const availability = total > 0 ? ((up / total) * 100).toFixed(1) : "-";
|
||||
|
||||
const data =
|
||||
total > 0
|
||||
? [
|
||||
{ name: "UP", value: up },
|
||||
{ name: "DOWN", value: down },
|
||||
]
|
||||
: [{ name: "EMPTY", value: 1 }];
|
||||
|
||||
const colors = total > 0 ? [UP_COLOR, DOWN_COLOR] : [EMPTY_COLOR];
|
||||
|
||||
return (
|
||||
<div className="status-donut">
|
||||
<ResponsiveContainer height={180} width="100%">
|
||||
<PieChart>
|
||||
<Pie cx="50%" cy="50%" data={data} dataKey="value" innerRadius={50} outerRadius={70} stroke="none">
|
||||
{data.map((item, index) => (
|
||||
<Cell fill={colors[index % colors.length]} key={item.name} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="donut-center-label">{total > 0 ? `${availability}%` : "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Col, Row, Statistic, Typography } from "tdesign-react";
|
||||
import { Card, Col, Row, Statistic } from "tdesign-react";
|
||||
|
||||
import type { DashboardResponse } from "../../shared/api";
|
||||
|
||||
import { formatRelativeTime, isOlderThan } from "../utils/time";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary: DashboardResponse["summary"] | null;
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
const cards = [
|
||||
@@ -25,25 +15,18 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
|
||||
{ color: "red" as const, label: "异常", value: summary.down },
|
||||
{ color: "orange" as const, label: `${summary.window.label} 异常事件数`, value: summary.incidents },
|
||||
];
|
||||
const freshnessWarning = isOlderThan(summary.lastCheckTime, 60000, now);
|
||||
|
||||
return (
|
||||
<section className="summary-cards-row">
|
||||
<Row gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Col key={card.label} span={3}>
|
||||
<Card bordered>
|
||||
<Card bordered={false}>
|
||||
<Row gutter={16}>
|
||||
{cards.map((card) => (
|
||||
<Col className="summary-stat-col" key={card.label} span={3}>
|
||||
<Statistic color={card.color} title={card.label} value={card.value} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Typography.Text
|
||||
className={freshnessWarning ? "summary-freshness summary-freshness--warning" : "summary-freshness"}
|
||||
theme="secondary"
|
||||
>
|
||||
{summary.lastCheckTime ? `最后更新: ${formatRelativeTime(summary.lastCheckTime, now)}` : "尚无检查数据"}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Space className="full-width" direction="vertical" size={32}>
|
||||
<Space className="full-width" direction="vertical" size={24}>
|
||||
{sortedGroups.map(([name, groupTargets]) => (
|
||||
<TargetGroup columns={columns} key={name} name={name} onTargetClick={onTargetClick} targets={groupTargets} />
|
||||
))}
|
||||
|
||||
@@ -85,28 +85,30 @@ export function TargetDetailDrawer({
|
||||
}
|
||||
onClose={onClose}
|
||||
placement="right"
|
||||
size="60%"
|
||||
size="52%"
|
||||
visible={!!target}
|
||||
>
|
||||
<Space className="full-width" direction="vertical" size={16}>
|
||||
<RadioGroup
|
||||
onChange={handleShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
theme="button"
|
||||
value={activeShortcut}
|
||||
variant="default-filled"
|
||||
/>
|
||||
<DateRangePicker
|
||||
className="full-width"
|
||||
defaultTime={["00:00:00", "23:59:00"]}
|
||||
enableTimePicker
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
mode="date"
|
||||
onChange={handleDateRangeChange}
|
||||
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
valueType="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
<div className="drawer-time-controls">
|
||||
<RadioGroup
|
||||
onChange={handleShortcut}
|
||||
options={TIME_SHORTCUTS.map((s) => ({ label: s.label, value: s.value }))}
|
||||
theme="button"
|
||||
value={activeShortcut}
|
||||
variant="default-filled"
|
||||
/>
|
||||
<DateRangePicker
|
||||
className="drawer-date-range"
|
||||
defaultTime={["00:00:00", "23:59:00"]}
|
||||
enableTimePicker
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
mode="date"
|
||||
onChange={handleDateRangeChange}
|
||||
timePickerProps={{ format: "HH:mm", steps: [1, 1, 60] }}
|
||||
value={timeFrom && timeTo ? [timeFrom, timeTo] : undefined}
|
||||
valueType="YYYY-MM-DD HH:mm"
|
||||
/>
|
||||
</div>
|
||||
<Tabs onChange={(val: TabValue) => setActiveTab(val)} value={activeTab}>
|
||||
<Tabs.TabPanel className="tab-panel-padded" label="概览" value="overview">
|
||||
<OverviewTab metricsData={metricsData} metricsLoading={metricsLoading} target={target} />
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { PrimaryTableCol } from "tdesign-react";
|
||||
|
||||
import { PrimaryTable } from "tdesign-react";
|
||||
import { Card, PrimaryTable, Space, Tag } from "tdesign-react";
|
||||
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
|
||||
interface TargetGroupProps {
|
||||
columns: Array<PrimaryTableCol<TargetStatus>>;
|
||||
name: string;
|
||||
@@ -16,12 +14,24 @@ interface TargetGroupProps {
|
||||
export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
|
||||
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
||||
const down = targets.length - up;
|
||||
const displayName = name === "default" ? "默认分组" : name;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GroupHeader down={down} name={name} total={targets.length} up={up} />
|
||||
<Card
|
||||
actions={
|
||||
<Space size={8}>
|
||||
<Tag theme="success" title="正常" variant="light">
|
||||
{up}
|
||||
</Tag>
|
||||
<Tag theme="danger" title="异常" variant="light">
|
||||
{down}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
headerBordered
|
||||
title={displayName}
|
||||
>
|
||||
<PrimaryTable
|
||||
bordered
|
||||
className="clickable-table"
|
||||
columns={columns}
|
||||
data={targets}
|
||||
@@ -36,6 +46,6 @@ export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGro
|
||||
size="small"
|
||||
stripe
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,13 +74,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
||||
return (
|
||||
<Tag size="small" theme={streak.up ? "success" : "danger"} variant="light">
|
||||
{streak.up ? "▲" : "▼"} {streak.count}
|
||||
{streak.capped ? "+" : "次"}
|
||||
{streak.capped ? "+" : ""}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
colKey: "currentStreak",
|
||||
title: "连续",
|
||||
width: 100,
|
||||
title: "连续(次)",
|
||||
width: 88,
|
||||
},
|
||||
{
|
||||
align: "right",
|
||||
@@ -88,19 +88,14 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
||||
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>;
|
||||
const latencyText = ms > 9999 ? "9999+ms" : `${Math.round(ms)}ms`;
|
||||
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
|
||||
},
|
||||
colKey: "latestCheck.durationMs",
|
||||
sorter: latencySorter,
|
||||
sortType: "all",
|
||||
title: "延迟",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
align: "center",
|
||||
colKey: "interval",
|
||||
title: "间隔",
|
||||
width: 72,
|
||||
width: 75,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ const queryKeys = {
|
||||
["metrics", targetId, from, to, bucket] as const,
|
||||
};
|
||||
|
||||
export const DASHBOARD_REFRESH_INTERVAL_MS = 8000;
|
||||
|
||||
export async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
@@ -19,7 +21,7 @@ export function useDashboard() {
|
||||
return useQuery({
|
||||
queryFn: () => fetchJson<DashboardResponse>("/api/dashboard?window=24h&recentLimit=30"),
|
||||
queryKey: queryKeys.dashboard(),
|
||||
refetchInterval: 8000,
|
||||
refetchInterval: DASHBOARD_REFRESH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,17 +12,44 @@
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
min-height: 100vh;
|
||||
background: var(--td-bg-color-page);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
box-sizing: border-box;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: var(--td-comp-margin-l);
|
||||
.dashboard-brand {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: var(--td-comp-margin-s);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dashboard-header .t-typography {
|
||||
.dashboard-logo {
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: calc(var(--td-font-size-title-large) + 6px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dashboard-refresh-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: var(--td-comp-margin-xxl);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -69,24 +96,6 @@
|
||||
background: var(--td-bg-color-component-disabled);
|
||||
}
|
||||
|
||||
.status-donut {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.donut-center-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -60%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -104,6 +113,7 @@
|
||||
}
|
||||
|
||||
.t-table tr.row-down {
|
||||
border-left: 3px solid var(--td-error-color);
|
||||
background: color-mix(in srgb, var(--td-error-color) 6%, transparent);
|
||||
}
|
||||
|
||||
@@ -131,6 +141,58 @@
|
||||
color: var(--td-error-color);
|
||||
}
|
||||
|
||||
.latency-value {
|
||||
display: inline-block;
|
||||
min-width: 7ch;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-time-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--td-comp-margin-m);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer-date-range {
|
||||
flex: 1;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.overview-stat-card {
|
||||
background: var(--td-bg-color-container-hover);
|
||||
}
|
||||
|
||||
.overview-stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--td-comp-margin-m);
|
||||
}
|
||||
|
||||
.overview-stat-value {
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.overview-stat-value .t-statistic-content {
|
||||
font-size: var(--td-font-size-body-medium);
|
||||
}
|
||||
|
||||
.summary-stat-col {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-time-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.drawer-date-range {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -139,33 +201,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
margin-bottom: var(--td-comp-margin-m);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-header .t-typography {
|
||||
margin: 0;
|
||||
font-size: var(--td-font-size-title-medium);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.summary-cards-row {
|
||||
margin-bottom: var(--td-comp-margin-xl);
|
||||
}
|
||||
|
||||
.summary-freshness {
|
||||
display: block;
|
||||
margin-top: var(--td-comp-margin-s);
|
||||
}
|
||||
|
||||
.summary-freshness--warning {
|
||||
color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.error-boundary-fallback {
|
||||
padding-top: 20vh;
|
||||
width: 100%;
|
||||
|
||||
@@ -34,7 +34,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||
}
|
||||
|
||||
describe("createTargetTableColumns", () => {
|
||||
test("生成 8 个目标表格列", () => {
|
||||
test("生成 7 个目标表格列", () => {
|
||||
const columns = createTargetTableColumns(["http", "cmd"]);
|
||||
|
||||
expect(columns.map((column) => column.colKey)).toEqual([
|
||||
@@ -45,7 +45,6 @@ describe("createTargetTableColumns", () => {
|
||||
"recentSamples",
|
||||
"currentStreak",
|
||||
"latestCheck.durationMs",
|
||||
"interval",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -96,6 +95,31 @@ describe("createTargetTableColumns", () => {
|
||||
rowIndex: 0,
|
||||
});
|
||||
|
||||
expect(streakColumn.title).toBe("连续(次)");
|
||||
expect(element.props.children.join("")).toBe("▼ 30+");
|
||||
});
|
||||
|
||||
test("延迟列超过 9999ms 时显示上限文案", () => {
|
||||
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
|
||||
const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
|
||||
props: { children: string; className: string };
|
||||
};
|
||||
const element = renderCell({
|
||||
col: latencyColumn,
|
||||
colIndex: 6,
|
||||
row: makeTarget({
|
||||
latestCheck: {
|
||||
durationMs: 12000,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
rowIndex: 0,
|
||||
});
|
||||
|
||||
expect(element.props.children).toBe("9999+ms");
|
||||
expect(element.props.className).toContain("latency-value");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user