feat: 重构 Dashboard 为卡片式分组布局
表格布局替换为按分组展示的卡片式布局,新增 group 字段配置和 TargetBoard/TargetCard 等组件。模态框详情页支持时间范围筛选和分页,SummaryCards 减为 3 个。API 端点变更:trend/history 改用 from/to 参数,history 支持分页。recentSampleCount 硬编码为 30。
This commit is contained in:
29
README.md
29
README.md
@@ -25,8 +25,8 @@ src/
|
||||
shared/
|
||||
api.ts 前后端共享 TypeScript 类型
|
||||
web/ Vite + React 前端 Dashboard
|
||||
components/ UI 组件
|
||||
hooks/ 数据轮询 hooks
|
||||
components/ UI 组件(卡片、分组、模态框、状态条等)
|
||||
hooks/ 数据轮询和详情管理 hooks
|
||||
scripts/ 开发、构建和 smoke test 脚本
|
||||
tests/ Bun test 测试
|
||||
openspec/ OpenSpec 变更与规格文档
|
||||
@@ -137,6 +137,7 @@ targets:
|
||||
- **targets**: 拨测目标列表(必填)
|
||||
- `name`: 目标名称(必填,唯一)
|
||||
- `type`: 目标类型,`http` 或 `command`(必填)
|
||||
- `group`: 分组名称(可选,默认 `"default"`)
|
||||
- `http`: HTTP 拨测配置(type 为 http 时必填)
|
||||
- `url`: 目标 URL
|
||||
- `method`、`headers`、`body`: 请求参数
|
||||
@@ -164,28 +165,32 @@ targets:
|
||||
|
||||
## API 端点
|
||||
|
||||
| 端点 | 说明 |
|
||||
| --------------------------------------- | ----------------------------------------------------- |
|
||||
| `GET /health` | 健康检查 |
|
||||
| `GET /api/summary` | 总览统计(total/up/down/avgDurationMs/lastCheckTime) |
|
||||
| `GET /api/targets` | 目标列表及最新状态和统计摘要 |
|
||||
| `GET /api/targets/:id/history?limit=20` | 指定目标的最近 N 条拨测记录 |
|
||||
| `GET /api/targets/:id/trend?hours=24` | 指定目标的按小时聚合趋势 |
|
||||
| 端点 | 说明 |
|
||||
| ----------------------------------------------------- | --------------------------------------- |
|
||||
| `GET /health` | 健康检查 |
|
||||
| `GET /api/summary` | 总览统计(total/up/down/lastCheckTime) |
|
||||
| `GET /api/targets` | 目标列表及最新状态、分组和采样数据 |
|
||||
| `GET /api/targets/:id/history?from=ISO&to=ISO&page=1` | 指定目标的拨测记录(时间范围 + 分页) |
|
||||
| `GET /api/targets/:id/trend?from=ISO&to=ISO` | 指定目标的按小时聚合趋势 |
|
||||
|
||||
### 响应字段
|
||||
|
||||
**SummaryResponse**: `total`、`up`、`down`、`avgDurationMs`、`lastCheckTime`
|
||||
**SummaryResponse**: `total`、`up`、`down`、`lastCheckTime`
|
||||
|
||||
**TargetStatus**: `id`、`name`、`type`(http/command)、`target`(URL 或命令摘要)、`interval`、`latestCheck`、`stats`、`sparkline`
|
||||
**TargetStatus**: `id`、`name`、`type`(http/command)、`target`(URL 或命令摘要)、`group`、`interval`、`latestCheck`、`stats`、`recentSamples`
|
||||
|
||||
**RecentSample**: `timestamp`、`durationMs`、`up`
|
||||
|
||||
**CheckResult**: `timestamp`、`success`、`matched`、`durationMs`、`statusDetail`、`failure`
|
||||
|
||||
**CheckFailure**: `kind`(error/mismatch)、`phase`、`path`、`expected`、`actual`、`message`
|
||||
|
||||
**TargetStats**: `totalChecks`、`availability`、`avgDurationMs`、`p99DurationMs`
|
||||
**TargetStats**: `totalChecks`、`availability`
|
||||
|
||||
**TrendPoint**: `hour`、`avgDurationMs`、`availability`、`totalChecks`
|
||||
|
||||
**HistoryResponse**: `items`(CheckResult[])、`total`、`page`、`pageSize`
|
||||
|
||||
## 代码质量
|
||||
|
||||
```bash
|
||||
|
||||
@@ -11,6 +11,7 @@ context: |
|
||||
- 禁止创建git操作task
|
||||
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||
- 优先使用提问工具对用户进行提问
|
||||
- (当前项目未上线,不需要考虑向前兼容)
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
|
||||
63
openspec/specs/card-dashboard/spec.md
Normal file
63
openspec/specs/card-dashboard/spec.md
Normal file
@@ -0,0 +1,63 @@
|
||||
## Purpose
|
||||
|
||||
定义 Dashboard 的卡片式分组布局:按分组展示目标卡片、响应式网格、卡片内容结构和交互行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 分组卡片布局
|
||||
Dashboard SHALL 按分组展示所有拨测目标,每个分组包含带统计的分组标题和固定宽度的卡片网格。
|
||||
|
||||
#### Scenario: 按分组展示目标
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 按分组展示目标卡片,"默认分组" 排在最上面,其余分组按 YAML 配置顺序排列
|
||||
|
||||
#### Scenario: 分组标题带统计
|
||||
- **WHEN** 页面渲染某个分组
|
||||
- **THEN** 分组标题 SHALL 显示分组名称、该分组内目标总数、正常数和异常数,格式为 `分组名 (N个, X UP / Y DOWN)`
|
||||
|
||||
#### Scenario: "default" 分组显示名称
|
||||
- **WHEN** 分组名称为 "default"
|
||||
- **THEN** 分组标题 SHALL 显示 "默认分组"
|
||||
|
||||
### Requirement: 响应式卡片网格
|
||||
Dashboard SHALL 使用固定宽度的卡片配合响应式网格布局。
|
||||
|
||||
#### Scenario: 卡片固定宽度
|
||||
- **WHEN** 页面渲染卡片
|
||||
- **THEN** 每个卡片 SHALL 固定宽度 280px
|
||||
|
||||
#### Scenario: 响应式列数
|
||||
- **WHEN** 视口宽度变化
|
||||
- **THEN** 卡片网格 SHALL 自动调整列数,使用 CSS Grid auto-fill 适配可用空间
|
||||
|
||||
### Requirement: 目标卡片内容
|
||||
每个目标卡片 SHALL 展示目标名称、当前状态、类型标签、状态条和迷你耗时趋势线。
|
||||
|
||||
#### Scenario: 卡片第一行内容
|
||||
- **WHEN** 卡片渲染
|
||||
- **THEN** 卡片第一行 SHALL 展示状态指示圆点(UP 绿色 / DOWN 红色)、目标名称和类型标签(HTTP / Command)
|
||||
|
||||
#### Scenario: 卡片状态指示圆点
|
||||
- **WHEN** 目标最近一次拨测 success=true 且 matched=true
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为绿色
|
||||
- **WHEN** 目标最近一次拨测 success=false 或 matched=false
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为红色
|
||||
|
||||
#### Scenario: 卡片状态条可视化
|
||||
- **WHEN** 卡片渲染且 recentSamples 数据可用
|
||||
- **THEN** 卡片 SHALL 展示一条 30 方块的状态条,每个采样点为一个色块:UP 显示绿色(#1fbf75),DOWN 显示红色(#e5484d),无数据显示灰色(#e2e8f0)
|
||||
|
||||
#### Scenario: 卡片迷你耗时趋势线
|
||||
- **WHEN** 卡片渲染且 recentSamples 中有 durationMs 数据
|
||||
- **THEN** 卡片 SHALL 展示基于 recharts 的迷你折线图(80x32px),展示最近 30 次检查的耗时趋势
|
||||
|
||||
### Requirement: 卡片交互
|
||||
卡片 SHALL 支持 hover 效果和点击打开模态框。
|
||||
|
||||
#### Scenario: 卡片 hover 效果
|
||||
- **WHEN** 鼠标悬停在卡片上
|
||||
- **THEN** 卡片 SHALL 显示上浮效果(阴影加深)
|
||||
|
||||
#### Scenario: 卡片点击打开详情
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 打开该目标的详情模态框
|
||||
@@ -1,48 +1,82 @@
|
||||
## Purpose
|
||||
|
||||
定义拨测系统的 REST API 端点:总览统计、目标列表含状态、历史记录和趋势聚合。
|
||||
定义拨测系统的 REST API 端点:总览统计、目标列表含分组和结构化采样数据、带时间范围和分页的历史记录、按时间范围的趋势聚合。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 总览统计 API
|
||||
系统 SHALL 提供 `GET /api/summary` 端点,返回所有目标的总体统计信息。
|
||||
系统 SHALL 提供 `GET /api/summary` 端点,返回所有目标的总体统计信息(不含平均耗时)。
|
||||
|
||||
#### Scenario: 获取总览统计
|
||||
- **WHEN** 客户端请求 `GET /api/summary`
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 total(总目标数)、up(正常数)、down(异常数)、avgDurationMs(所有目标平均耗时)、lastCheckTime(最近一次检查时间)
|
||||
- **THEN** 系统 SHALL 返回 JSON 包含 total(总目标数)、up(正常数)、down(异常数)、lastCheckTime(最近一次检查时间)
|
||||
|
||||
### Requirement: 目标列表 API
|
||||
系统 SHALL 提供 `GET /api/targets` 端点,返回所有 typed target 及其最新状态和统计摘要。
|
||||
系统 SHALL 提供 `GET /api/targets` 端点,返回所有 typed target 及其最新状态、分组信息和结构化采样数据。
|
||||
|
||||
#### Scenario: 获取目标列表
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息(id、name、type、target、interval)、最近一次检查结果(timestamp、success、matched、durationMs、statusDetail、failure)和统计摘要(totalChecks、availability、avgDurationMs、p99DurationMs)
|
||||
- **THEN** 系统 SHALL 返回 JSON 数组,每个元素包含目标基本信息(id、name、group、type、target、interval)、最近一次检查结果(timestamp、success、matched、durationMs、statusDetail、failure)、统计摘要(totalChecks、availability)和结构化采样数据 recentSamples(代替原 sparkline)
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某目标尚未执行过任何拨测
|
||||
- **THEN** 其 latestCheck 为 null,stats 中 totalChecks 为 0
|
||||
- **THEN** 其 latestCheck 为 null,recentSamples 为空数组
|
||||
|
||||
### Requirement: 历史记录 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/history` 端点,返回指定目标的最近 N 条拨测记录。
|
||||
系统 SHALL 提供 `GET /api/targets/:id/history` 端点,支持时间范围筛选和分页返回指定目标的拨测记录。
|
||||
|
||||
#### Scenario: 获取最近历史记录
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=20`
|
||||
- **THEN** 系统 SHALL 返回最多 20 条检查记录,按时间倒序排列,且每条包含 success、matched、durationMs、statusDetail 和 failure
|
||||
#### Scenario: 获取指定时间范围内的历史记录
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=1&pageSize=20`
|
||||
- **THEN** 系统 SHALL 返回带分页信息的历史记录,包含 items、total、page、pageSize,按时间倒序排列
|
||||
|
||||
#### Scenario: 使用默认 limit
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history`(未指定 limit)
|
||||
- **THEN** 系统 SHALL 默认返回最近 20 条记录
|
||||
#### Scenario: 使用默认分页参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO`(未指定 page 或 pageSize)
|
||||
- **THEN** 系统 SHALL 使用默认 page=1, pageSize=20
|
||||
|
||||
### Requirement: 趋势聚合 API
|
||||
系统 SHALL 提供 `GET /api/targets/:id/trend` 端点,返回指定目标按小时聚合的趋势数据。
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 获取 24 小时趋势
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend?hours=24`
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,每个数据点包含 hour、avgDurationMs、availability、totalChecks
|
||||
### Requirement: 趋势 API 支持时间范围
|
||||
系统 SHALL 提供 `GET /api/targets/:id/trend` 端点,支持 `from` 和 `to` 查询参数指定时间范围。
|
||||
|
||||
#### Scenario: 使用默认时间范围
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend`(未指定 hours)
|
||||
- **THEN** 系统 SHALL 默认返回最近 24 小时的趋势数据
|
||||
#### Scenario: 指定时间范围查询趋势
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend?from=ISO&to=ISO`
|
||||
- **THEN** 系统 SHALL 返回指定时间范围内按小时分组的聚合数据
|
||||
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
### Requirement: 目标列表返回分组和采样数据
|
||||
`GET /api/targets` SHALL 返回每个目标的分组信息和结构化采样数据,替代原有 sparkline。
|
||||
|
||||
#### Scenario: 返回分组信息
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 响应中每个目标 SHALL 包含 `group` 字段,值为该目标所属的分组名称
|
||||
|
||||
#### Scenario: 返回 recentSamples
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 响应中每个目标 SHALL 包含 `recentSamples` 数组,每个元素包含 `timestamp`(ISO 8601)、`durationMs`(number | null)、`up`(boolean,success && matched)
|
||||
|
||||
#### Scenario: recentSamples 数量
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 每个目标的 recentSamples SHALL 最多包含 30 个元素,按时间倒序排列
|
||||
|
||||
#### Scenario: 目标无历史记录
|
||||
- **WHEN** 某目标尚未执行过任何拨测
|
||||
- **THEN** 其 recentSamples SHALL 为空数组
|
||||
|
||||
### Requirement: 新增共享类型
|
||||
系统 SHALL 在 `src/shared/api.ts` 中定义 `RecentSample` 和 `HistoryResponse` 类型。
|
||||
|
||||
#### Scenario: RecentSample 类型
|
||||
- **WHEN** 前后端共享 `RecentSample` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `timestamp: string`、`durationMs: number | null`、`up: boolean` 字段
|
||||
|
||||
#### Scenario: HistoryResponse 类型
|
||||
- **WHEN** 前后端共享 `HistoryResponse` 类型
|
||||
- **THEN** 该类型 SHALL 包含 `items: CheckResult[]`、`total: number`、`page: number`、`pageSize: number` 字段
|
||||
|
||||
### Requirement: 保留健康检查端点
|
||||
系统 SHALL 保留 `GET /health` 端点,不受拨测功能影响。
|
||||
@@ -58,8 +92,20 @@
|
||||
- **WHEN** 客户端请求 `GET /api/targets/999/history`
|
||||
- **THEN** 系统 SHALL 返回 404 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的 limit 参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?limit=abc`
|
||||
#### Scenario: 无效的 from/to 参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=invalid`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的分页参数
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/history?from=ISO&to=ISO&page=abc`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: from 或 to 参数缺失
|
||||
- **WHEN** 客户端请求 `GET /api/targets/1/trend` 未提供 from 或 to 参数
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
#### Scenario: 无效的目标 ID
|
||||
- **WHEN** 客户端请求 `GET /api/targets/abc/history`
|
||||
- **THEN** 系统 SHALL 返回 400 状态码和错误信息
|
||||
|
||||
### Requirement: 失败信息 API 契约
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
## Requirements
|
||||
|
||||
### Requirement: YAML 配置文件格式
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,command 领域字段 MUST 放在 `command` 分组。
|
||||
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `type` 字段声明 checker 类型,HTTP 领域字段 MUST 放在 `http` 分组,command 领域字段 MUST 放在 `command` 分组。
|
||||
|
||||
#### Scenario: 完整配置文件解析
|
||||
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets 的 YAML 配置文件
|
||||
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets(含 group 字段)的 YAML 配置文件
|
||||
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
|
||||
|
||||
#### Scenario: 最简 HTTP 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect)
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB)
|
||||
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, group="default")
|
||||
|
||||
#### Scenario: 最简 command 配置文件解析
|
||||
- **WHEN** 系统读取只包含一个 `type: command` target 和 `command.exec` 的 YAML 配置文件
|
||||
@@ -61,6 +61,10 @@
|
||||
- **WHEN** YAML 中存在两个 name 相同的 target
|
||||
- **THEN** 系统 SHALL 以错误退出,提示重复的 name
|
||||
|
||||
#### Scenario: group 字段类型校验
|
||||
- **WHEN** YAML 中某个 target 的 `group` 字段不是字符串
|
||||
- **THEN** 系统 SHALL 以错误退出并提示 group 字段类型错误
|
||||
|
||||
#### Scenario: interval 格式非法
|
||||
- **WHEN** interval 或 timeout 值不是有效的时长格式(如 `30s`、`5m`、`500ms`)
|
||||
- **THEN** 系统 SHALL 以错误退出并提示格式错误
|
||||
|
||||
@@ -1,79 +1,126 @@
|
||||
## Purpose
|
||||
|
||||
定义拨测系统的 React 前端 Dashboard:统计卡片、目标列表表格、可展开详情面板和趋势图可视化。
|
||||
定义拨测系统的 React 前端 Dashboard:统计卡片、按分组卡片式布局、状态条和迷你趋势线可视化、目标详情模态框和时间范围筛选。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 总览统计卡片
|
||||
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数、异常数和平均耗时。
|
||||
Dashboard SHALL 在页面顶部展示总览统计卡片,包含总目标数、正常数和异常数(移除平均耗时)。
|
||||
|
||||
#### Scenario: 展示统计卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面顶部 SHALL 显示 4 个统计卡片:全部目标数、正常目标数、异常目标数、所有目标平均耗时
|
||||
- **THEN** 页面顶部 SHALL 显示 3 个统计卡片:全部目标数、正常目标数、异常目标数
|
||||
|
||||
#### Scenario: 统计数据自动刷新
|
||||
- **WHEN** 页面处于打开状态
|
||||
- **THEN** 统计卡片 SHALL 每 5-10 秒自动刷新数据
|
||||
|
||||
### Requirement: 目标列表表格
|
||||
Dashboard SHALL 展示所有 checker target 的列表表格,包含名称、类型、目标摘要、当前状态、最新耗时、最近失败原因和迷你趋势线。
|
||||
### Requirement: 卡片式分组布局
|
||||
Dashboard SHALL 使用按分组展示的卡片式布局替代表格布局,每个分组包含带统计的分组标题和响应式卡片网格。
|
||||
|
||||
#### Scenario: 展示目标列表
|
||||
#### Scenario: 按分组渲染卡片
|
||||
- **WHEN** 用户打开 Dashboard 页面
|
||||
- **THEN** 页面 SHALL 显示表格,每行包含目标名称、类型、目标摘要、状态指示圆点(UP / DOWN)、最新耗时值、最近失败原因摘要、迷你 Sparkline 趋势线
|
||||
- **THEN** 页面 SHALL 按 group 字段将目标分组展示,每组一个区域,"默认分组" 排在最上面
|
||||
|
||||
#### Scenario: 状态指示圆点
|
||||
#### Scenario: 无分组时的展示
|
||||
- **WHEN** 所有目标均属于 "default" 分组
|
||||
- **THEN** 页面 SHALL 显示一个 "默认分组" 区域,卡片正常展示
|
||||
|
||||
### Requirement: 分组标题展示
|
||||
Dashboard SHALL 在每个分组区域上方显示带统计信息的分组标题。
|
||||
|
||||
#### Scenario: 显示分组统计
|
||||
- **WHEN** 渲染分组区域
|
||||
- **THEN** 分组标题 SHALL 显示格式为 `分组名 (N个, X UP / Y DOWN)` 的统计信息
|
||||
|
||||
#### Scenario: default 分组标题
|
||||
- **WHEN** 分组名为 "default"
|
||||
- **THEN** 标题 SHALL 显示 "默认分组"
|
||||
|
||||
### Requirement: 响应式卡片网格
|
||||
Dashboard SHALL 使用固定宽度的卡片配合响应式网格布局。
|
||||
|
||||
#### Scenario: 卡片固定宽度
|
||||
- **WHEN** 页面渲染卡片
|
||||
- **THEN** 每个卡片 SHALL 固定宽度 280px
|
||||
|
||||
#### Scenario: 响应式列数
|
||||
- **WHEN** 视口宽度变化
|
||||
- **THEN** 卡片网格 SHALL 自动调整列数,使用 CSS Grid auto-fill 适配可用空间
|
||||
|
||||
### Requirement: 目标卡片内容
|
||||
每个目标卡片 SHALL 展示目标名称、当前状态、类型标签、状态条和迷你耗时趋势线。
|
||||
|
||||
#### Scenario: 卡片第一行内容
|
||||
- **WHEN** 卡片渲染
|
||||
- **THEN** 卡片第一行 SHALL 展示状态指示圆点(UP 绿色 / DOWN 红色)、目标名称和类型标签(HTTP / Command)
|
||||
|
||||
#### Scenario: 卡片状态指示圆点
|
||||
- **WHEN** 目标最近一次拨测 success=true 且 matched=true
|
||||
- **THEN** 状态圆点 SHALL 显示为绿色(UP)
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为绿色
|
||||
- **WHEN** 目标最近一次拨测 success=false 或 matched=false
|
||||
- **THEN** 状态圆点 SHALL 显示为红色(DOWN)
|
||||
- **THEN** 卡片状态圆点 SHALL 显示为红色
|
||||
|
||||
### Requirement: 可展开的目标详情面板
|
||||
Dashboard SHALL 支持在目标列表中展开某行,显示该目标的详细状态、统计摘要、趋势图和最近历史记录。
|
||||
#### Scenario: 卡片状态条可视化
|
||||
- **WHEN** 卡片渲染且 recentSamples 数据可用
|
||||
- **THEN** 卡片 SHALL 展示一条状态条,每个采样点为一个色块:UP 显示绿色(#1fbf75),DOWN 显示红色(#e5484d),无数据显示灰色(#e2e8f0)
|
||||
|
||||
#### Scenario: 展开目标详情
|
||||
- **WHEN** 用户点击目标列表中的某一行
|
||||
- **THEN** 该行下方 SHALL 展开详情面板,包含:可用率百分比、平均耗时、P99 耗时、24 小时耗时趋势折线图、最近 5-10 条检查记录列表、领域状态详情和失败信息
|
||||
#### Scenario: 卡片迷你耗时趋势线
|
||||
- **WHEN** 卡片渲染且 recentSamples 中有 durationMs 数据
|
||||
- **THEN** 卡片 SHALL 展示基于 recharts 的迷你折线图(80x32px),展示最近 30 次检查的耗时趋势
|
||||
|
||||
#### Scenario: 收起目标详情
|
||||
- **WHEN** 用户再次点击已展开的目标行
|
||||
- **THEN** 详情面板 SHALL 收起
|
||||
### Requirement: 卡片交互
|
||||
卡片 SHALL 支持 hover 效果和点击打开模态框。
|
||||
|
||||
#### Scenario: 趋势图按需加载
|
||||
- **WHEN** 用户展开某个目标的详情面板
|
||||
- **THEN** 系统 SHALL 此时请求该目标的趋势数据,而非页面加载时预加载所有目标的趋势数据
|
||||
#### Scenario: 卡片 hover 效果
|
||||
- **WHEN** 鼠标悬停在卡片上
|
||||
- **THEN** 卡片 SHALL 显示上浮效果(阴影加深)
|
||||
|
||||
### Requirement: 历史记录展示
|
||||
Dashboard SHALL 在目标详情面板中展示最近的检查记录,包含时间、领域状态详情、耗时、成功/失败标记和失败信息。
|
||||
#### Scenario: 卡片点击打开详情
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 打开该目标的详情模态框
|
||||
|
||||
#### Scenario: 展示历史记录
|
||||
- **WHEN** 用户展开目标详情面板
|
||||
- **THEN** 面板 SHALL 显示最近检查记录列表,每条包含时间戳、statusDetail(如 HTTP 200 或 exitCode=1)、耗时毫秒数、UP/DOWN 标记和 failure.message(如存在)
|
||||
### Requirement: 目标详情模态框
|
||||
Dashboard SHALL 提供模态框展示目标详情,包含时间范围筛选、多维统计图和分页检查记录列表。
|
||||
|
||||
### Requirement: 趋势图可视化
|
||||
Dashboard SHALL 使用 recharts 库渲染趋势图,包括目标列表中的迷你 Sparkline 和详情面板中的完整折线图。
|
||||
#### Scenario: 打开模态框
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 弹出模态框,占据视口 80% 宽度,展示该目标的详情
|
||||
|
||||
#### Scenario: 表格行内迷你趋势线
|
||||
- **WHEN** 目标列表表格渲染
|
||||
- **THEN** 每行 SHALL 包含一个基于 recharts 的迷你折线图,展示最近的耗时趋势
|
||||
#### Scenario: 模态框默认时间范围
|
||||
- **WHEN** 模态框打开
|
||||
- **THEN** 筛选器 SHALL 默认选中"最近 24 小时"
|
||||
|
||||
#### Scenario: 详情面板完整趋势图
|
||||
- **WHEN** 用户展开目标详情面板
|
||||
- **THEN** 面板 SHALL 展示基于 recharts 的完整折线图,X 轴为时间(小时),Y 轴为平均耗时,并标注可用率
|
||||
#### Scenario: 模态框布局
|
||||
- **WHEN** 模态框打开
|
||||
- **THEN** 模态框 SHALL 占据视口 80% 宽度,图表区在上方展示统计图,检查记录列表在下方展示
|
||||
|
||||
### Requirement: checker 类型展示
|
||||
Dashboard SHALL 在列表和详情中明确展示 target 的 checker 类型。
|
||||
#### Scenario: 快捷时间范围按钮
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 筛选栏 SHALL 显示快捷按钮:1h、6h、24h、7d,当前选中的按钮高亮显示
|
||||
|
||||
#### Scenario: 展示 HTTP 类型
|
||||
- **WHEN** 目标 type 为 `http`
|
||||
- **THEN** Dashboard SHALL 在类型列显示 HTTP,并将目标摘要显示为 URL
|
||||
#### Scenario: 点击快捷按钮
|
||||
- **WHEN** 用户点击快捷按钮(如 "24h")
|
||||
- **THEN** 筛选器 SHALL 自动设置对应的起止时间,日期选择器显示对应的时间范围,该按钮高亮
|
||||
|
||||
#### Scenario: 展示 command 类型
|
||||
- **WHEN** 目标 type 为 `command`
|
||||
- **THEN** Dashboard SHALL 在类型列显示 Command,并将目标摘要显示为命令摘要
|
||||
#### Scenario: 自定义日期时间选择
|
||||
- **WHEN** 用户通过日期时间选择器修改起止时间(分钟精度)
|
||||
- **THEN** 快捷按钮 SHALL 取消高亮,表示当前为自定义时间范围
|
||||
|
||||
#### Scenario: 关闭模态框
|
||||
- **WHEN** 用户点击模态框关闭按钮或模态框外部区域
|
||||
- **THEN** 模态框 SHALL 关闭
|
||||
|
||||
#### Scenario: 统计图表
|
||||
- **WHEN** 模态框加载完成
|
||||
- **THEN** 图表区 SHALL 展示可用率趋势折线图、耗时趋势折线图和状态分布环形图
|
||||
|
||||
#### Scenario: 检查记录分页
|
||||
- **WHEN** 检查记录超过一页
|
||||
- **THEN** 检查记录列表底部 SHALL 展示分页器
|
||||
|
||||
### Requirement: 页面加载与错误状态
|
||||
Dashboard SHALL 正确处理加载状态和 API 错误。
|
||||
Dashboard SHALL 正确处理加载状态和 API 错误,适配卡片式布局。
|
||||
|
||||
#### Scenario: 首次加载
|
||||
- **WHEN** 页面首次加载且数据尚未返回
|
||||
@@ -82,3 +129,7 @@ Dashboard SHALL 正确处理加载状态和 API 错误。
|
||||
#### Scenario: API 请求失败
|
||||
- **WHEN** 前端轮询 API 请求失败
|
||||
- **THEN** 页面 SHALL 显示错误提示,并在下一次轮询周期自动重试
|
||||
|
||||
#### Scenario: 模态框内部加载状态
|
||||
- **WHEN** 模态框内趋势数据或历史记录正在加载
|
||||
- **THEN** 对应图表或列表区域 SHALL 显示加载指示
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
## Purpose
|
||||
|
||||
定义基于 SQLite 的拨测数据持久化存储:targets 同步、check_results 追加写入、索引与聚合查询。
|
||||
定义基于 SQLite 的拨测数据持久化存储:targets 同步(含分组信息)、check_results 追加写入、结构化采样数据查询、时间范围和分页查询、索引与聚合查询。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: SQLite 数据库初始化
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果。
|
||||
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果,targets 表 MUST 包含 `grp` 列存储分组信息。
|
||||
|
||||
#### Scenario: 首次启动创建数据库
|
||||
- **WHEN** 指定的数据目录下不存在数据库文件
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化包含 type、target、config、duration_ms、status_detail、failure 等字段的 targets 和 check_results 表
|
||||
- **THEN** 系统 SHALL 创建数据库文件并初始化包含 type、target、config、grp、duration_ms、status_detail、failure 等字段的 targets 和 check_results 表
|
||||
|
||||
#### Scenario: 数据目录不存在
|
||||
- **WHEN** 配置的数据目录路径不存在
|
||||
@@ -20,15 +20,15 @@
|
||||
- **THEN** 系统 SHALL 直接打开数据库,不重新建表
|
||||
|
||||
### Requirement: targets 表同步
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置和 expect 配置。
|
||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。
|
||||
|
||||
#### Scenario: 首次同步目标
|
||||
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms 和 expect
|
||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms、expect 和 grp
|
||||
|
||||
#### Scenario: 配置变更后重新同步
|
||||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||||
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新
|
||||
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
|
||||
|
||||
### Requirement: check_results 表追加写入
|
||||
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
||||
@@ -48,6 +48,42 @@
|
||||
- **WHEN** 查询指定 target_id 的最近 N 条记录
|
||||
- **THEN** 系统 SHALL 使用索引快速定位,无需全表扫描
|
||||
|
||||
### Requirement: 目标列表按分组排序
|
||||
系统 SHALL 保证 targets 查询结果按分组排序返回。
|
||||
|
||||
#### Scenario: 分组排序查询
|
||||
- **WHEN** 查询所有 targets
|
||||
- **THEN** 结果 SHALL 将 "default" 分组目标排在首位,其余分组按分组名称和目标插入顺序排列
|
||||
|
||||
### Requirement: 结构化采样数据查询
|
||||
系统 SHALL 提供 `getRecentSamples` 方法替代 `getSparkline`,返回包含状态信息的结构化采样数据。
|
||||
|
||||
#### Scenario: 获取最近采样数据
|
||||
- **WHEN** 调用 `getRecentSamples(targetId, 30)`
|
||||
- **THEN** 系统 SHALL 返回最多 30 条记录,每条包含 timestamp、duration_ms、success、matched
|
||||
|
||||
#### Scenario: 采样数据排序
|
||||
- **WHEN** 获取采样数据
|
||||
- **THEN** 记录 SHALL 按 timestamp 降序排列(最新在前)
|
||||
|
||||
### Requirement: 趋势数据时间范围查询
|
||||
系统 SHALL 支持按任意时间范围查询趋势聚合数据,替代固定 hours 参数。
|
||||
|
||||
#### Scenario: 按时间范围查询趋势
|
||||
- **WHEN** 查询指定 target 在 from 到 to 时间范围内的趋势数据
|
||||
- **THEN** 系统 SHALL 返回按小时分组的聚合数据,包括每小时的 avgDurationMs、availability 和 totalChecks
|
||||
|
||||
### Requirement: 历史记录时间范围和分页查询
|
||||
系统 SHALL 支持按时间范围筛选并分页查询历史记录。
|
||||
|
||||
#### Scenario: 按时间范围筛选历史记录
|
||||
- **WHEN** 查询指定 target 在 from 到 to 时间范围内的历史记录
|
||||
- **THEN** 系统 SHALL 返回该时间范围内的记录,按 timestamp 降序排列
|
||||
|
||||
#### Scenario: 分页查询历史记录
|
||||
- **WHEN** 查询指定 page 和 pageSize 的历史记录
|
||||
- **THEN** 系统 SHALL 返回对应页的数据和总记录数
|
||||
|
||||
### Requirement: 聚合查询支持
|
||||
数据存储 SHALL 支持按时间段聚合查询,用于计算可用率、平均耗时、P99 耗时等统计指标。
|
||||
|
||||
|
||||
76
openspec/specs/target-detail-modal/spec.md
Normal file
76
openspec/specs/target-detail-modal/spec.md
Normal file
@@ -0,0 +1,76 @@
|
||||
## Purpose
|
||||
|
||||
定义目标详情模态框:时间范围筛选(快捷按钮 + 日期选择器)、多维统计图(可用率趋势、耗时趋势、状态分布环形图)和分页检查结果列表。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 目标详情模态框
|
||||
Dashboard SHALL 在用户点击目标卡片后弹出模态框,展示该目标的详细统计图表和检查结果列表。
|
||||
|
||||
#### Scenario: 打开模态框
|
||||
- **WHEN** 用户点击某个目标卡片
|
||||
- **THEN** 系统 SHALL 弹出模态框,占据视口 80% 宽度,展示该目标的详情
|
||||
|
||||
#### Scenario: 模态框默认时间范围
|
||||
- **WHEN** 模态框打开
|
||||
- **THEN** 筛选器 SHALL 默认选中"最近 24 小时"
|
||||
|
||||
#### Scenario: 关闭模态框
|
||||
- **WHEN** 用户点击模态框关闭按钮或模态框外部区域
|
||||
- **THEN** 模态框 SHALL 关闭
|
||||
|
||||
### Requirement: 时间范围筛选
|
||||
模态框 SHALL 支持通过快捷按钮和自定义日期时间选择器筛选数据的时间范围。
|
||||
|
||||
#### Scenario: 快捷时间范围按钮
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 筛选栏 SHALL 显示快捷按钮:1h、6h、24h、7d,当前选中的按钮高亮显示
|
||||
|
||||
#### Scenario: 点击快捷按钮
|
||||
- **WHEN** 用户点击快捷按钮(如 "24h")
|
||||
- **THEN** 筛选器 SHALL 自动设置对应的起止时间,日期选择器显示对应的时间范围,该按钮高亮
|
||||
|
||||
#### Scenario: 自定义日期时间选择
|
||||
- **WHEN** 用户通过日期时间选择器修改起止时间(分钟精度)
|
||||
- **THEN** 快捷按钮 SHALL 取消高亮,表示当前为自定义时间范围
|
||||
|
||||
#### Scenario: 筛选触发数据刷新
|
||||
- **WHEN** 时间范围发生变化(快捷按钮或自定义选择)
|
||||
- **THEN** 系统 SHALL 重新请求该时间范围内的趋势数据和历史记录
|
||||
|
||||
### Requirement: 统计图表展示
|
||||
模态框图表区 SHALL 展示可用率趋势折线图、耗时趋势折线图和状态分布环形图。
|
||||
|
||||
#### Scenario: 可用率趋势折线图
|
||||
- **WHEN** 模态框加载完成且趋势数据可用
|
||||
- **THEN** 图表区 SHALL 展示可用率随时间变化的折线图,Y 轴为可用率百分比
|
||||
|
||||
#### Scenario: 耗时趋势折线图
|
||||
- **WHEN** 模态框加载完成且趋势数据可用
|
||||
- **THEN** 图表区 SHALL 展示耗时随时间变化的折线图,Y 轴为耗时毫秒数
|
||||
|
||||
#### Scenario: 状态分布环形图
|
||||
- **WHEN** 模态框加载完成
|
||||
- **THEN** 图表区 SHALL 展示环形图(Donut Chart),外圈显示 UP/DOWN 比例(绿色/红色),中间显示可用率百分比数字
|
||||
|
||||
### Requirement: 检查结果列表
|
||||
模态框检查记录列表 SHALL 展示当前筛选时间范围内的检查结果列表,支持分页浏览。
|
||||
|
||||
#### Scenario: 展示检查结果
|
||||
- **WHEN** 模态框加载完成且历史记录可用
|
||||
- **THEN** 检查记录列表 SHALL 展示检查结果,每条包含时间戳、UP/DOWN 状态标记、耗时毫秒数、statusDetail 和 failure 信息
|
||||
|
||||
#### Scenario: 分页导航
|
||||
- **WHEN** 检查结果总数超过一页
|
||||
- **THEN** 列表底部 SHALL 展示分页器,用户可点击切换页码
|
||||
|
||||
#### Scenario: 翻页刷新
|
||||
- **WHEN** 用户点击分页器切换页码
|
||||
- **THEN** 系统 SHALL 请求对应页码的历史记录数据,列表更新
|
||||
|
||||
### Requirement: 模态框布局
|
||||
模态框 SHALL 采用自上而下布局,上方展示统计图表,下方展示检查记录列表。
|
||||
|
||||
#### Scenario: 自上而下渲染
|
||||
- **WHEN** 模态框渲染
|
||||
- **THEN** 内容区域 SHALL 分为上下两部分,上方展示统计图表,下方展示检查结果列表和分页器
|
||||
45
openspec/specs/target-grouping/spec.md
Normal file
45
openspec/specs/target-grouping/spec.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## Purpose
|
||||
|
||||
定义 target 分组能力:YAML 配置中的 group 字段、后端存储、API 传递和前端分组排序。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: target 分组配置
|
||||
系统 SHALL 支持在每个 target 上配置可选的 `group` 字段,用于将目标归类到不同分组。未指定 `group` 的 target SHALL 归入 `"default"` 分组。
|
||||
|
||||
#### Scenario: 配置分组名称
|
||||
- **WHEN** YAML 配置中某个 target 指定 `group: "搜索引擎"`
|
||||
- **THEN** 系统 SHALL 将该 target 归类到 "搜索引擎" 分组
|
||||
|
||||
#### Scenario: 不配置分组
|
||||
- **WHEN** YAML 配置中某个 target 未指定 `group` 字段
|
||||
- **THEN** 系统 SHALL 将该 target 归类到 "default" 分组
|
||||
|
||||
#### Scenario: group 字段类型校验
|
||||
- **WHEN** YAML 配置中某个 target 的 `group` 字段不是字符串
|
||||
- **THEN** 系统 SHALL 以错误退出并提示 group 字段类型错误
|
||||
|
||||
### Requirement: 分组排序
|
||||
系统 SHALL 保证 "default" 分组始终排在最前面,其余分组按配置文件中首次出现的顺序排列。
|
||||
|
||||
#### Scenario: default 分组排最前
|
||||
- **WHEN** 配置中存在多个分组(包括 "default" 和自定义分组)
|
||||
- **THEN** API 返回的目标列表中 "default" 分组的目标 SHALL 排在其他分组之前
|
||||
|
||||
#### Scenario: 自定义分组按出现顺序
|
||||
- **WHEN** 配置中 "搜索引擎" 分组在 "后端服务" 分组之前首次出现
|
||||
- **THEN** API 返回中 "搜索引擎" 分组 SHALL 排在 "后端服务" 分组之前
|
||||
|
||||
### Requirement: 分组信息 API 传递
|
||||
系统 SHALL 在 API 响应中返回每个 target 的分组信息。
|
||||
|
||||
#### Scenario: targets 列表包含分组
|
||||
- **WHEN** 客户端请求 `GET /api/targets`
|
||||
- **THEN** 响应中每个目标 SHALL 包含 `group` 字段,值为该目标所属的分组名称
|
||||
|
||||
### Requirement: 分组存储
|
||||
系统 SHALL 在数据库 targets 表中持久化每个 target 的分组信息。
|
||||
|
||||
#### Scenario: 持久化分组信息
|
||||
- **WHEN** 系统同步 targets 到数据库
|
||||
- **THEN** 每个 target 的 `grp` 列 SHALL 存储其分组名称,未配置分组的存储 `"default"`
|
||||
@@ -20,6 +20,7 @@ targets:
|
||||
|
||||
- name: "Baidu 首页可用"
|
||||
type: http
|
||||
group: "搜索引擎"
|
||||
http:
|
||||
url: "https://www.baidu.com"
|
||||
expect:
|
||||
@@ -28,6 +29,7 @@ targets:
|
||||
|
||||
- name: "JSON API — 完整流水线"
|
||||
type: http
|
||||
group: "后端服务"
|
||||
interval: "1m"
|
||||
timeout: "15s"
|
||||
http:
|
||||
@@ -128,6 +130,7 @@ targets:
|
||||
|
||||
- name: "uname 输出匹配"
|
||||
type: command
|
||||
group: "系统检查"
|
||||
command:
|
||||
exec: "uname"
|
||||
args: ["-s"]
|
||||
|
||||
@@ -112,7 +112,7 @@ async function main() {
|
||||
const store = new ProbeStore(config.dataDir + "/probe.db");
|
||||
store.syncTargets(config.targets);
|
||||
|
||||
const engine = new ProbeEngine(store, config.targets);
|
||||
const engine = new ProbeEngine(store, config.targets, config.maxConcurrentChecks);
|
||||
engine.start();
|
||||
|
||||
startServer({
|
||||
|
||||
@@ -12,9 +12,14 @@ await assertExecutableExists(executablePath);
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "gc-smoke-"));
|
||||
const configPath = join(tempDir, "probes.yaml");
|
||||
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`targets:
|
||||
`server:
|
||||
port: ${port}
|
||||
targets:
|
||||
- name: "httpbin"
|
||||
type: http
|
||||
http:
|
||||
@@ -25,9 +30,6 @@ writeFileSync(
|
||||
status: [200]
|
||||
`,
|
||||
);
|
||||
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const app = Bun.spawn([executablePath, configPath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CheckFailure,
|
||||
CheckResult,
|
||||
HealthResponse,
|
||||
HistoryResponse,
|
||||
RuntimeMode,
|
||||
SummaryResponse,
|
||||
TargetStatus,
|
||||
@@ -97,20 +98,47 @@ function handleHistory(idStr: string, url: URL, method: string, store: ProbeStor
|
||||
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const limitParam = url.searchParams.get("limit");
|
||||
let limit = 20;
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
|
||||
if (limitParam !== null) {
|
||||
limit = Number(limitParam);
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
return jsonResponse(createApiError("Invalid limit parameter", 400), { method, mode, status: 400 });
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
let page = 1;
|
||||
let pageSize = 20;
|
||||
|
||||
if (pageParam !== null) {
|
||||
page = Number(pageParam);
|
||||
if (!Number.isInteger(page) || page <= 0) {
|
||||
return jsonResponse(createApiError("Invalid page parameter", 400), { method, mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const rows = store.getHistory(id, limit);
|
||||
const results: CheckResult[] = rows.map(mapCheckResult);
|
||||
if (pageSizeParam !== null) {
|
||||
pageSize = Number(pageSizeParam);
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) {
|
||||
return jsonResponse(createApiError("Invalid pageSize parameter", 400), { method, mode, status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse(results, { method, mode });
|
||||
const result = store.getHistory(id, from, to, page, pageSize);
|
||||
const response: HistoryResponse = {
|
||||
items: result.items.map(mapCheckResult),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
};
|
||||
|
||||
return jsonResponse(response, { method, mode });
|
||||
}
|
||||
|
||||
function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore, mode: RuntimeMode): Response {
|
||||
@@ -125,17 +153,20 @@ function handleTrend(idStr: string, url: URL, method: string, store: ProbeStore,
|
||||
return jsonResponse(createApiError("Target not found", 404), { method, mode, status: 404 });
|
||||
}
|
||||
|
||||
const hoursParam = url.searchParams.get("hours");
|
||||
let hours = 24;
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
|
||||
if (hoursParam !== null) {
|
||||
hours = Number(hoursParam);
|
||||
if (!Number.isInteger(hours) || hours <= 0) {
|
||||
return jsonResponse(createApiError("Invalid hours parameter", 400), { method, mode, status: 400 });
|
||||
}
|
||||
if (!from || !to) {
|
||||
return jsonResponse(createApiError("from and to parameters are required", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const trend: TrendPoint[] = store.getTrend(id, hours).map((row) => ({
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return jsonResponse(createApiError("Invalid from or to parameter format", 400), { method, mode, status: 400 });
|
||||
}
|
||||
|
||||
const trend: TrendPoint[] = store.getTrend(id, from, to).map((row) => ({
|
||||
hour: row.hour,
|
||||
avgDurationMs: row.avgDurationMs,
|
||||
availability: Math.round(row.availability * 100) / 100,
|
||||
@@ -151,7 +182,6 @@ function createSummaryResponse(store: ProbeStore): SummaryResponse {
|
||||
total: summary.total,
|
||||
up: summary.up,
|
||||
down: summary.down,
|
||||
avgDurationMs: summary.avgDurationMs,
|
||||
lastCheckTime: summary.lastCheckTime,
|
||||
};
|
||||
}
|
||||
@@ -162,20 +192,24 @@ function createTargetsResponse(store: ProbeStore): TargetStatus[] {
|
||||
return targets.map((target) => {
|
||||
const latest = store.getLatestCheck(target.id);
|
||||
const stats = store.getTargetStats(target.id);
|
||||
const recentSamples = store.getRecentSamples(target.id, 30);
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
type: target.type,
|
||||
target: target.target,
|
||||
group: target.grp,
|
||||
interval: formatDuration(target.interval_ms),
|
||||
latestCheck: latest ? mapCheckResult(latest) : null,
|
||||
sparkline: store.getSparkline(target.id),
|
||||
recentSamples: recentSamples.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
durationMs: s.duration_ms,
|
||||
up: s.success === 1 && s.matched === 1,
|
||||
})),
|
||||
stats: {
|
||||
totalChecks: stats.totalChecks,
|
||||
availability: stats.availability,
|
||||
avgDurationMs: stats.avgDurationMs,
|
||||
p99DurationMs: stats.p99DurationMs,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -100,12 +100,13 @@ function resolveTarget(
|
||||
): ResolvedTarget {
|
||||
const intervalMs = parseDuration(target.interval ?? defaults.interval ?? DEFAULT_INTERVAL);
|
||||
const timeoutMs = parseDuration(target.timeout ?? defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
const group = target.group ?? "default";
|
||||
|
||||
if (target.type === "http") {
|
||||
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs);
|
||||
return resolveHttpTarget(target, defaults.http, intervalMs, timeoutMs, group);
|
||||
}
|
||||
|
||||
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir);
|
||||
return resolveCommandTarget(target, defaults.command, intervalMs, timeoutMs, configDir, group);
|
||||
}
|
||||
|
||||
function resolveHttpTarget(
|
||||
@@ -113,12 +114,14 @@ function resolveHttpTarget(
|
||||
httpDefaults: HttpDefaultsConfig | undefined,
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
group: string,
|
||||
): ResolvedHttpTarget {
|
||||
const maxBodyBytes = parseSize(target.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
||||
|
||||
return {
|
||||
type: "http",
|
||||
name: target.name,
|
||||
group,
|
||||
http: {
|
||||
url: target.http.url,
|
||||
method: target.http.method ?? httpDefaults?.method ?? DEFAULT_HTTP_METHOD,
|
||||
@@ -138,6 +141,7 @@ function resolveCommandTarget(
|
||||
intervalMs: number,
|
||||
timeoutMs: number,
|
||||
configDir: string,
|
||||
group: string,
|
||||
): ResolvedCommandTarget {
|
||||
const cwd = target.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(configDir, cwd);
|
||||
@@ -151,6 +155,7 @@ function resolveCommandTarget(
|
||||
return {
|
||||
type: "command",
|
||||
name: target.name,
|
||||
group,
|
||||
command: {
|
||||
exec: target.command.exec,
|
||||
args: target.command.args ?? [],
|
||||
@@ -202,6 +207,11 @@ function validateConfig(config: ProbeConfig): void {
|
||||
}
|
||||
}
|
||||
|
||||
const group = raw["group"];
|
||||
if (group !== undefined && typeof group !== "string") {
|
||||
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
|
||||
}
|
||||
|
||||
if (names.has(name as string)) {
|
||||
throw new Error(`target name 重复: "${name}"`);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS targets (
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
interval_ms INTEGER NOT NULL,
|
||||
timeout_ms INTEGER NOT NULL,
|
||||
expect TEXT
|
||||
expect TEXT,
|
||||
grp TEXT NOT NULL DEFAULT 'default'
|
||||
)
|
||||
`;
|
||||
|
||||
@@ -58,10 +59,10 @@ export class ProbeStore {
|
||||
const configNames = new Set(targets.map((t) => t.name));
|
||||
|
||||
const insertStmt = this.db.prepare(
|
||||
"INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
const updateStmt = this.db.prepare(
|
||||
"UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ? WHERE id = ?",
|
||||
"UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
|
||||
);
|
||||
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
|
||||
|
||||
@@ -73,9 +74,9 @@ export class ProbeStore {
|
||||
const expect = t.expect ? JSON.stringify(t.expect) : null;
|
||||
|
||||
if (existingMap.has(t.name)) {
|
||||
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, existingMap.get(t.name)!);
|
||||
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, existingMap.get(t.name)!);
|
||||
} else {
|
||||
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect);
|
||||
insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +92,9 @@ export class ProbeStore {
|
||||
|
||||
getTargets(): StoredTarget[] {
|
||||
if (this.closed) return [];
|
||||
return this.db.query("SELECT * FROM targets ORDER BY id").all() as StoredTarget[];
|
||||
return this.db
|
||||
.query("SELECT * FROM targets ORDER BY CASE WHEN grp='default' THEN 0 ELSE 1 END, grp, id")
|
||||
.all() as StoredTarget[];
|
||||
}
|
||||
|
||||
getTargetById(id: number): StoredTarget | null {
|
||||
@@ -130,39 +133,40 @@ export class ProbeStore {
|
||||
.get(targetId) as StoredCheckResult | null;
|
||||
}
|
||||
|
||||
getHistory(targetId: number, limit = 20): StoredCheckResult[] {
|
||||
return this.db
|
||||
.prepare("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?")
|
||||
.all(targetId, limit) as StoredCheckResult[];
|
||||
getHistory(
|
||||
targetId: number,
|
||||
from: string,
|
||||
to: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): { items: StoredCheckResult[]; total: number; page: number; pageSize: number } {
|
||||
const countRow = this.db
|
||||
.prepare("SELECT COUNT(*) as total FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?")
|
||||
.get(targetId, from, to) as { total: number };
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const items = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM check_results WHERE target_id = ? AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.all(targetId, from, to, pageSize, offset) as StoredCheckResult[];
|
||||
|
||||
return { items, total: countRow.total, page, pageSize };
|
||||
}
|
||||
|
||||
getTargetStats(targetId: number): {
|
||||
totalChecks: number;
|
||||
availability: number;
|
||||
avgDurationMs: number | null;
|
||||
p99DurationMs: number | null;
|
||||
} {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as totalChecks,
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount,
|
||||
AVG(CASE WHEN success = 1 THEN duration_ms END) as avgDurationMs
|
||||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) as upCount
|
||||
FROM check_results
|
||||
WHERE target_id = ?`,
|
||||
)
|
||||
.get(targetId) as { totalChecks: number; upCount: number; avgDurationMs: number | null };
|
||||
|
||||
const p99Row = this.db
|
||||
.prepare(
|
||||
`SELECT duration_ms as p99DurationMs
|
||||
FROM check_results
|
||||
WHERE target_id = ? AND success = 1
|
||||
ORDER BY duration_ms DESC
|
||||
LIMIT 1
|
||||
OFFSET (SELECT COUNT(*) FROM check_results WHERE target_id = ? AND success = 1) * 99 / 100`,
|
||||
)
|
||||
.get(targetId, targetId) as { p99DurationMs: number | null } | undefined;
|
||||
.get(targetId) as { totalChecks: number; upCount: number };
|
||||
|
||||
const totalChecks = row.totalChecks;
|
||||
const availability = totalChecks > 0 ? (row.upCount / totalChecks) * 100 : 0;
|
||||
@@ -170,14 +174,13 @@ export class ProbeStore {
|
||||
return {
|
||||
totalChecks,
|
||||
availability: Math.round(availability * 100) / 100,
|
||||
avgDurationMs: row.avgDurationMs !== null ? Math.round(row.avgDurationMs * 100) / 100 : null,
|
||||
p99DurationMs: p99Row?.p99DurationMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
getTrend(
|
||||
targetId: number,
|
||||
hours = 24,
|
||||
from: string,
|
||||
to: string,
|
||||
): Array<{
|
||||
hour: string;
|
||||
avgDurationMs: number | null;
|
||||
@@ -192,11 +195,11 @@ export class ProbeStore {
|
||||
CASE WHEN COUNT(*) > 0 THEN (SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)) ELSE 0 END as availability,
|
||||
COUNT(*) as totalChecks
|
||||
FROM check_results
|
||||
WHERE target_id = ? AND timestamp >= datetime('now', '-' || ? || ' hours')
|
||||
WHERE target_id = ? AND timestamp >= ? AND timestamp <= ?
|
||||
GROUP BY hour
|
||||
ORDER BY hour`,
|
||||
)
|
||||
.all(targetId, hours) as Array<{
|
||||
.all(targetId, from, to) as Array<{
|
||||
hour: string;
|
||||
avgDurationMs: number | null;
|
||||
availability: number;
|
||||
@@ -208,14 +211,11 @@ export class ProbeStore {
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
avgDurationMs: number | null;
|
||||
lastCheckTime: string | null;
|
||||
} {
|
||||
const targets = this.getTargets();
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let totalDuration = 0;
|
||||
let durationCount = 0;
|
||||
let lastCheckTime: string | null = null;
|
||||
|
||||
for (const target of targets) {
|
||||
@@ -228,11 +228,6 @@ export class ProbeStore {
|
||||
down++;
|
||||
}
|
||||
|
||||
if (latest.duration_ms !== null) {
|
||||
totalDuration += latest.duration_ms;
|
||||
durationCount++;
|
||||
}
|
||||
|
||||
if (!lastCheckTime || latest.timestamp > lastCheckTime) {
|
||||
lastCheckTime = latest.timestamp;
|
||||
}
|
||||
@@ -245,18 +240,24 @@ export class ProbeStore {
|
||||
total: targets.length,
|
||||
up,
|
||||
down,
|
||||
avgDurationMs: durationCount > 0 ? Math.round((totalDuration / durationCount) * 100) / 100 : null,
|
||||
lastCheckTime,
|
||||
};
|
||||
}
|
||||
|
||||
getSparkline(targetId: number, limit = 20): number[] {
|
||||
const rows = this.db
|
||||
getRecentSamples(
|
||||
targetId: number,
|
||||
limit: number,
|
||||
): Array<{ timestamp: string; duration_ms: number | null; success: number; matched: number }> {
|
||||
return this.db
|
||||
.prepare(
|
||||
"SELECT duration_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?",
|
||||
"SELECT timestamp, duration_ms, success, matched FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT ?",
|
||||
)
|
||||
.all(targetId, limit) as Array<{ duration_ms: number }>;
|
||||
return rows.map((r) => r.duration_ms).reverse();
|
||||
.all(targetId, limit) as Array<{
|
||||
timestamp: string;
|
||||
duration_ms: number | null;
|
||||
success: number;
|
||||
matched: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
||||
@@ -56,6 +56,7 @@ export type TargetConfig = BaseTargetConfig &
|
||||
|
||||
interface BaseTargetConfig {
|
||||
name: string;
|
||||
group?: string;
|
||||
interval?: string;
|
||||
timeout?: string;
|
||||
expect?: ExpectConfig;
|
||||
@@ -111,6 +112,7 @@ export type ExpectConfig = HttpExpectConfig | CommandExpectConfig;
|
||||
export interface ResolvedHttpTarget {
|
||||
type: "http";
|
||||
name: string;
|
||||
group: string;
|
||||
http: ResolvedHttpConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
@@ -128,6 +130,7 @@ export interface ResolvedHttpConfig {
|
||||
export interface ResolvedCommandTarget {
|
||||
type: "command";
|
||||
name: string;
|
||||
group: string;
|
||||
command: ResolvedCommandConfig;
|
||||
intervalMs: number;
|
||||
timeoutMs: number;
|
||||
@@ -172,6 +175,7 @@ export interface StoredTarget {
|
||||
interval_ms: number;
|
||||
timeout_ms: number;
|
||||
expect: string | null;
|
||||
grp: string;
|
||||
}
|
||||
|
||||
export interface StoredCheckResult {
|
||||
|
||||
@@ -15,26 +15,37 @@ export interface SummaryResponse {
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
avgDurationMs: number | null;
|
||||
lastCheckTime: string | null;
|
||||
}
|
||||
|
||||
export interface RecentSample {
|
||||
timestamp: string;
|
||||
durationMs: number | null;
|
||||
up: boolean;
|
||||
}
|
||||
|
||||
export interface TargetStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
target: string;
|
||||
group: string;
|
||||
interval: string;
|
||||
latestCheck: CheckResult | null;
|
||||
stats: TargetStats;
|
||||
sparkline: number[];
|
||||
recentSamples: RecentSample[];
|
||||
}
|
||||
|
||||
export interface TargetStats {
|
||||
totalChecks: number;
|
||||
availability: number;
|
||||
avgDurationMs: number | null;
|
||||
p99DurationMs: number | null;
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
items: CheckResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSummary } from "./hooks/useSummary";
|
||||
import { useTargets } from "./hooks/useTargets";
|
||||
import { useTargetDetail } from "./hooks/useTargetDetail";
|
||||
import { SummaryCards } from "./components/SummaryCards";
|
||||
import { TargetTable } from "./components/TargetTable";
|
||||
import { TargetBoard } from "./components/TargetBoard";
|
||||
import { TargetDetailModal } from "./components/TargetDetailModal";
|
||||
|
||||
export function App() {
|
||||
const { data: summary, loading: summaryLoading, error: summaryError } = useSummary();
|
||||
const { data: targets, loading: targetsLoading, error: targetsError } = useTargets();
|
||||
const { data: targets, error: targetsError } = useTargets();
|
||||
const {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
} = useTargetDetail();
|
||||
|
||||
const error = summaryError || targetsError;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTarget) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [selectedTarget]);
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
@@ -19,7 +46,22 @@ export function App() {
|
||||
{error && <div className="error-banner">请求失败: {error},将在下一次轮询周期自动重试</div>}
|
||||
|
||||
<SummaryCards summary={summary} loading={summaryLoading} />
|
||||
<TargetTable targets={targets} loading={targetsLoading} />
|
||||
<TargetBoard targets={targets} onTargetClick={openModal} />
|
||||
|
||||
{selectedTarget && (
|
||||
<TargetDetailModal
|
||||
target={selectedTarget}
|
||||
trendData={trendData}
|
||||
trendLoading={trendLoading}
|
||||
historyData={historyData}
|
||||
historyLoading={historyLoading}
|
||||
timeFrom={timeFrom}
|
||||
timeTo={timeTo}
|
||||
onTimeChange={handleTimeChange}
|
||||
onPageChange={handlePageChange}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
17
src/web/components/CardGrid.tsx
Normal file
17
src/web/components/CardGrid.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { TargetCard } from "./TargetCard";
|
||||
|
||||
interface CardGridProps {
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
}
|
||||
|
||||
export function CardGrid({ targets, onTargetClick }: CardGridProps) {
|
||||
return (
|
||||
<div className="card-grid">
|
||||
{targets.map((target) => (
|
||||
<TargetCard key={target.id} target={target} onClick={() => onTargetClick(target)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/web/components/GroupHeader.tsx
Normal file
19
src/web/components/GroupHeader.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface GroupHeaderProps {
|
||||
name: string;
|
||||
total: number;
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
export function GroupHeader({ name, total, up, down }: GroupHeaderProps) {
|
||||
const displayName = name === "default" ? "默认分组" : name;
|
||||
|
||||
return (
|
||||
<div className="group-header">
|
||||
<h2 className="group-title">{displayName}</h2>
|
||||
<span className="group-stats">
|
||||
({total}个, {up} UP / {down} DOWN)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/web/components/MiniSparkline.tsx
Normal file
25
src/web/components/MiniSparkline.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Line, LineChart, ResponsiveContainer } from "recharts";
|
||||
import type { RecentSample } from "../../shared/api";
|
||||
|
||||
interface MiniSparklineProps {
|
||||
data: RecentSample[];
|
||||
}
|
||||
|
||||
export function MiniSparkline({ data }: MiniSparklineProps) {
|
||||
const chartData = data
|
||||
.filter((s) => s.durationMs !== null)
|
||||
.map((s) => ({ duration: s.durationMs! }))
|
||||
.reverse();
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return <span className="sparkline-empty">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width={80} height={32}>
|
||||
<LineChart data={chartData}>
|
||||
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
47
src/web/components/Pagination.tsx
Normal file
47
src/web/components/Pagination.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages: number[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
const visiblePages = pages.filter((p) => {
|
||||
if (totalPages <= 7) return true;
|
||||
if (p === 1 || p === totalPages) return true;
|
||||
if (Math.abs(p - page) <= 1) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
<button className="pagination-btn" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
|
||||
<
|
||||
</button>
|
||||
{visiblePages.map((p, idx) => {
|
||||
const prev = visiblePages[idx - 1];
|
||||
const showEllipsis = prev !== undefined && p - prev > 1;
|
||||
return (
|
||||
<span key={p} className="pagination-items">
|
||||
{showEllipsis && <span className="pagination-ellipsis">...</span>}
|
||||
<button className={`pagination-btn ${p === page ? "active" : ""}`} onClick={() => onPageChange(p)}>
|
||||
{p}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button className="pagination-btn" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Line, LineChart, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface SparklineChartProps {
|
||||
data: Array<{ duration: number }>;
|
||||
}
|
||||
|
||||
export function SparklineChart({ data }: SparklineChartProps) {
|
||||
if (data.length === 0) {
|
||||
return <span className="sparkline-empty">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width={80} height={32}>
|
||||
<LineChart data={data}>
|
||||
<Line type="monotone" dataKey="duration" stroke="#356dd2" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
17
src/web/components/StatusBar.tsx
Normal file
17
src/web/components/StatusBar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
interface StatusBarProps {
|
||||
samples: Array<{ up: boolean }>;
|
||||
}
|
||||
|
||||
export function StatusBar({ samples }: StatusBarProps) {
|
||||
const blocks = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const sample = samples[i];
|
||||
if (sample) {
|
||||
blocks.push(<span key={i} className={`status-bar-block ${sample.up ? "status-bar-up" : "status-bar-down"}`} />);
|
||||
} else {
|
||||
blocks.push(<span key={i} className="status-bar-block status-bar-empty" />);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="status-bar">{blocks}</div>;
|
||||
}
|
||||
40
src/web/components/StatusDonut.tsx
Normal file
40
src/web/components/StatusDonut.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface StatusDonutProps {
|
||||
up: number;
|
||||
down: number;
|
||||
}
|
||||
|
||||
const UP_COLOR = "#1fbf75";
|
||||
const DOWN_COLOR = "#e5484d";
|
||||
const EMPTY_COLOR = "#e2e8f0";
|
||||
|
||||
export function StatusDonut({ up, down }: 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 width="100%" height={180}>
|
||||
<PieChart>
|
||||
<Pie data={data} cx="50%" cy="50%" innerRadius={50} outerRadius={70} dataKey="value" stroke="none">
|
||||
{data.map((_, index) => (
|
||||
<Cell key={index} fill={colors[index % colors.length]!} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="donut-center-label">{availability}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,11 +16,6 @@ export function SummaryCards({ summary, loading }: SummaryCardsProps) {
|
||||
{ label: "全部目标", value: summary.total, className: "card-total" },
|
||||
{ label: "正常", value: summary.up, className: "card-up" },
|
||||
{ label: "异常", value: summary.down, className: "card-down" },
|
||||
{
|
||||
label: "平均耗时",
|
||||
value: summary.avgDurationMs !== null ? `${Math.round(summary.avgDurationMs)}ms` : "-",
|
||||
className: "card-latency",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
28
src/web/components/TargetBoard.tsx
Normal file
28
src/web/components/TargetBoard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { TargetGroup } from "./TargetGroup";
|
||||
|
||||
interface TargetBoardProps {
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
}
|
||||
|
||||
export function TargetBoard({ targets, onTargetClick }: TargetBoardProps) {
|
||||
const groups = new Map<string, TargetStatus[]>();
|
||||
for (const target of targets) {
|
||||
const group = target.group;
|
||||
const list = groups.get(group);
|
||||
if (list) {
|
||||
list.push(target);
|
||||
} else {
|
||||
groups.set(group, [target]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="target-board">
|
||||
{Array.from(groups.entries()).map(([name, groupTargets]) => (
|
||||
<TargetGroup key={name} name={name} targets={groupTargets} onTargetClick={onTargetClick} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/web/components/TargetCard.tsx
Normal file
27
src/web/components/TargetCard.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { MiniSparkline } from "./MiniSparkline";
|
||||
|
||||
interface TargetCardProps {
|
||||
target: TargetStatus;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TargetCard({ target, onClick }: TargetCardProps) {
|
||||
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
|
||||
|
||||
return (
|
||||
<div className="target-card" onClick={onClick} role="button" tabIndex={0}>
|
||||
<div className="card-row-1">
|
||||
<StatusDot up={!!isUp} />
|
||||
<span className="card-name">{target.name}</span>
|
||||
<span className="card-type-badge">{target.type === "http" ? "HTTP" : "Command"}</span>
|
||||
</div>
|
||||
<div className="card-row-2">
|
||||
<StatusBar samples={target.recentSamples} />
|
||||
<MiniSparkline data={target.recentSamples} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { CheckResult, TargetStatus } from "../../shared/api";
|
||||
import { useTrend } from "../hooks/useTrend";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
|
||||
interface TargetDetailProps {
|
||||
target: TargetStatus;
|
||||
}
|
||||
|
||||
export function TargetDetail({ target }: TargetDetailProps) {
|
||||
const { data: trendData, loading: trendLoading, fetchTrend } = useTrend(target.id);
|
||||
const [history, setHistory] = useState<CheckResult[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/targets/${target.id}/history?limit=10`);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as CheckResult[];
|
||||
setHistory(data);
|
||||
}
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [target.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrend();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchHistory();
|
||||
}, [fetchTrend, fetchHistory]);
|
||||
|
||||
const { stats } = target;
|
||||
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={6} className="detail-cell">
|
||||
<div className="target-detail">
|
||||
<div className="detail-stats">
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">状态</span>
|
||||
<span className={`detail-stat-value ${isUp ? "text-up" : "text-down"}`}>{isUp ? "UP" : "DOWN"}</span>
|
||||
</div>
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">可用率</span>
|
||||
<span className="detail-stat-value">
|
||||
{stats.totalChecks > 0 ? `${stats.availability.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">平均耗时</span>
|
||||
<span className="detail-stat-value">
|
||||
{stats.avgDurationMs !== null ? `${Math.round(stats.avgDurationMs)}ms` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-stat">
|
||||
<span className="detail-stat-label">P99 耗时</span>
|
||||
<span className="detail-stat-value">
|
||||
{stats.p99DurationMs !== null ? `${Math.round(stats.p99DurationMs)}ms` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-trend">
|
||||
<h4>24 小时趋势</h4>
|
||||
<TrendChart data={trendData} loading={trendLoading} />
|
||||
</div>
|
||||
|
||||
<div className="detail-history">
|
||||
<h4>最近检查记录</h4>
|
||||
{historyLoading ? (
|
||||
<p className="history-empty">加载中...</p>
|
||||
) : history.length > 0 ? (
|
||||
<div className="history-list">
|
||||
{history.map((item, idx) => (
|
||||
<div key={idx} className="history-item">
|
||||
<span className={`history-status ${item.success && item.matched ? "text-up" : "text-down"}`}>
|
||||
{item.success && item.matched ? "UP" : "DOWN"}
|
||||
</span>
|
||||
<span className="history-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</span>
|
||||
{item.statusDetail && <span className="history-code">{item.statusDetail}</span>}
|
||||
{item.durationMs !== null && (
|
||||
<span className="history-latency">{Math.round(item.durationMs)}ms</span>
|
||||
)}
|
||||
{item.failure?.message && <span className="history-error">{item.failure.message}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="history-empty">暂无检查记录</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
121
src/web/components/TargetDetailModal.tsx
Normal file
121
src/web/components/TargetDetailModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { CheckResult, TargetStatus, TrendPoint } from "../../shared/api";
|
||||
import { TrendChart } from "./TrendChart";
|
||||
import { StatusDonut } from "./StatusDonut";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { TimeRangePicker } from "./TimeRangePicker";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface TargetDetailModalProps {
|
||||
target: TargetStatus;
|
||||
trendData: TrendPoint[];
|
||||
trendLoading: boolean;
|
||||
historyData: HistoryData;
|
||||
historyLoading: boolean;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
onTimeChange: (from: string, to: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface HistoryData {
|
||||
items: CheckResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function TargetDetailModal({
|
||||
target,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
onTimeChange,
|
||||
onPageChange,
|
||||
onClose,
|
||||
}: TargetDetailModalProps) {
|
||||
const isUp = target.latestCheck?.success && 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);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title-row">
|
||||
<StatusDot up={!!isUp} />
|
||||
<h3 className="modal-title">{target.name}</h3>
|
||||
<span className="card-type-badge">{target.type === "http" ? "HTTP" : "Command"}</span>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TimeRangePicker from={timeFrom} to={timeTo} onChange={onTimeChange} />
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="modal-charts">
|
||||
<div className="modal-chart-section">
|
||||
<h4>状态分布</h4>
|
||||
<StatusDonut up={upChecks} down={totalChecks - upChecks} />
|
||||
</div>
|
||||
<div className="modal-chart-section">
|
||||
<h4>趋势图</h4>
|
||||
<TrendChart data={trendData} loading={trendLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-history-section">
|
||||
<h4>检查记录</h4>
|
||||
{historyLoading ? (
|
||||
<div className="history-loading">加载中...</div>
|
||||
) : historyData.items.length > 0 ? (
|
||||
<>
|
||||
<table className="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="ht-col-status">状态</th>
|
||||
<th className="ht-col-time">时间</th>
|
||||
<th className="ht-col-detail">详情</th>
|
||||
<th className="ht-col-latency">耗时</th>
|
||||
<th className="ht-col-error">错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historyData.items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<span className={`ht-status ${item.success && item.matched ? "text-up" : "text-down"}`}>
|
||||
{item.success && item.matched ? "UP" : "DOWN"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="ht-time">{new Date(item.timestamp).toLocaleString("zh-CN")}</td>
|
||||
<td className="ht-detail">{item.statusDetail ?? "-"}</td>
|
||||
<td className="ht-latency">
|
||||
{item.durationMs !== null ? `${Math.round(item.durationMs)}ms` : "-"}
|
||||
</td>
|
||||
<td className="ht-error">{item.failure?.message ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
page={historyData.page}
|
||||
pageSize={historyData.pageSize}
|
||||
total={historyData.total}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="history-empty">暂无检查记录</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/web/components/TargetGroup.tsx
Normal file
21
src/web/components/TargetGroup.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
import { CardGrid } from "./CardGrid";
|
||||
|
||||
interface TargetGroupProps {
|
||||
name: string;
|
||||
targets: TargetStatus[];
|
||||
onTargetClick: (target: TargetStatus) => void;
|
||||
}
|
||||
|
||||
export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) {
|
||||
const up = targets.filter((t) => t.latestCheck?.success && t.latestCheck?.matched).length;
|
||||
const down = targets.length - up;
|
||||
|
||||
return (
|
||||
<div className="target-group">
|
||||
<GroupHeader name={name} total={targets.length} up={up} down={down} />
|
||||
<CardGrid targets={targets} onTargetClick={onTargetClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { SparklineChart } from "./SparklineChart";
|
||||
|
||||
interface TargetRowProps {
|
||||
target: TargetStatus;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function TargetRow({ target, expanded, onToggle }: TargetRowProps) {
|
||||
const isUp = target.latestCheck?.success && target.latestCheck?.matched;
|
||||
|
||||
const sparklineData = target.sparkline.map((duration) => ({ duration }));
|
||||
|
||||
return (
|
||||
<tr className={`target-row ${expanded ? "expanded" : ""}`} onClick={onToggle}>
|
||||
<td className="col-status">
|
||||
<StatusDot up={!!isUp} />
|
||||
</td>
|
||||
<td className="col-name">{target.name}</td>
|
||||
<td className="col-target">{target.target}</td>
|
||||
<td className="col-type">{target.type === "http" ? "HTTP" : "Command"}</td>
|
||||
<td className="col-duration">
|
||||
{target.latestCheck?.durationMs !== null && target.latestCheck?.durationMs !== undefined
|
||||
? `${Math.round(target.latestCheck.durationMs)}ms`
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="col-sparkline">
|
||||
<SparklineChart data={sparklineData} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { TargetRow } from "./TargetRow";
|
||||
import { TargetDetail } from "./TargetDetail";
|
||||
|
||||
interface TargetTableProps {
|
||||
targets: TargetStatus[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function TargetTable({ targets, loading }: TargetTableProps) {
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
if (loading && targets.length === 0) {
|
||||
return <div className="table-loading">加载目标列表...</div>;
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
return <div className="table-empty">暂无拨测目标</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="target-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-status">状态</th>
|
||||
<th className="col-name">名称</th>
|
||||
<th className="col-target">目标</th>
|
||||
<th className="col-type">类型</th>
|
||||
<th className="col-duration">耗时</th>
|
||||
<th className="col-sparkline">趋势</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{targets.map((target) => {
|
||||
const isExpanded = expandedId === target.id;
|
||||
return (
|
||||
<TargetRowWrapper
|
||||
key={target.id}
|
||||
target={target}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setExpandedId(isExpanded ? null : target.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetRowWrapper({
|
||||
target,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
target: TargetStatus;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TargetRow target={target} expanded={expanded} onToggle={onToggle} />
|
||||
{expanded && <TargetDetail target={target} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
src/web/components/TimeRangePicker.tsx
Normal file
80
src/web/components/TimeRangePicker.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface TimeRangePickerProps {
|
||||
from: string;
|
||||
to: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
}
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ label: "1h", hours: 1 },
|
||||
{ label: "6h", hours: 6 },
|
||||
{ label: "24h", hours: 24 },
|
||||
{ label: "7d", hours: 168 },
|
||||
];
|
||||
|
||||
function toLocalDatetimeInput(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function subtractHours(date: Date, hours: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function TimeRangePicker({ from, to, onChange }: TimeRangePickerProps) {
|
||||
const [activeShortcut, setActiveShortcut] = useState<string | null>("24h");
|
||||
|
||||
const handleShortcut = (label: string, hours: number) => {
|
||||
const now = new Date();
|
||||
const newFrom = subtractHours(now, hours);
|
||||
onChange(newFrom.toISOString(), now.toISOString());
|
||||
setActiveShortcut(label);
|
||||
};
|
||||
|
||||
const handleFromChange = (value: string) => {
|
||||
onChange(new Date(value).toISOString(), to);
|
||||
setActiveShortcut(null);
|
||||
};
|
||||
|
||||
const handleToChange = (value: string) => {
|
||||
onChange(from, new Date(value).toISOString());
|
||||
setActiveShortcut(null);
|
||||
};
|
||||
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
|
||||
return (
|
||||
<div className="time-range-picker">
|
||||
<div className="time-shortcuts">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
className={`time-shortcut-btn ${activeShortcut === s.label ? "active" : ""}`}
|
||||
onClick={() => handleShortcut(s.label, s.hours)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="time-inputs">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="time-input"
|
||||
value={toLocalDatetimeInput(fromDate)}
|
||||
onChange={(e) => handleFromChange(e.target.value)}
|
||||
/>
|
||||
<span className="time-separator">~</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="time-input"
|
||||
value={toLocalDatetimeInput(toDate)}
|
||||
onChange={(e) => handleToChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/web/hooks/useHistory.ts
Normal file
35
src/web/hooks/useHistory.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { HistoryResponse } from "../../shared/api";
|
||||
|
||||
export function useHistory(targetId: number | null) {
|
||||
const [data, setData] = useState<HistoryResponse>({ items: [], total: 0, page: 1, pageSize: 15 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchHistory = useCallback(
|
||||
async (from: string, to: string, page = 1, pageSize = 15) => {
|
||||
if (targetId === null) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/targets/${targetId}/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&page=${page}&pageSize=${pageSize}`,
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as HistoryResponse;
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[targetId],
|
||||
);
|
||||
|
||||
return { data, error, loading, fetchHistory };
|
||||
}
|
||||
77
src/web/hooks/useTargetDetail.ts
Normal file
77
src/web/hooks/useTargetDetail.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TargetStatus } from "../../shared/api";
|
||||
import { useTrend } from "./useTrend";
|
||||
import { useHistory } from "./useHistory";
|
||||
|
||||
export function useTargetDetail() {
|
||||
const [selectedTarget, setSelectedTarget] = useState<TargetStatus | null>(null);
|
||||
const [timeFrom, setTimeFrom] = useState<string>("");
|
||||
const [timeTo, setTimeTo] = useState<string>("");
|
||||
|
||||
const { data: trendData, loading: trendLoading, fetchTrend } = useTrend(selectedTarget?.id ?? null);
|
||||
const { data: historyData, loading: historyLoading, fetchHistory } = useHistory(selectedTarget?.id ?? null);
|
||||
const initialFetchRef = useRef(false);
|
||||
|
||||
const openModal = useCallback((target: TargetStatus) => {
|
||||
setSelectedTarget(target);
|
||||
const now = new Date();
|
||||
const from = subtractHours(now, 24);
|
||||
setTimeFrom(from.toISOString());
|
||||
setTimeTo(now.toISOString());
|
||||
initialFetchRef.current = false;
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setSelectedTarget(null);
|
||||
initialFetchRef.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTarget && timeFrom && timeTo && !initialFetchRef.current) {
|
||||
initialFetchRef.current = true;
|
||||
fetchTrend(timeFrom, timeTo);
|
||||
fetchHistory(timeFrom, timeTo);
|
||||
}
|
||||
}, [selectedTarget, timeFrom, timeTo, fetchTrend, fetchHistory]);
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(from: string, to: string) => {
|
||||
setTimeFrom(from);
|
||||
setTimeTo(to);
|
||||
if (selectedTarget) {
|
||||
fetchTrend(from, to);
|
||||
fetchHistory(from, to);
|
||||
}
|
||||
},
|
||||
[fetchTrend, fetchHistory, selectedTarget],
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(page: number) => {
|
||||
if (timeFrom && timeTo) {
|
||||
fetchHistory(timeFrom, timeTo, page);
|
||||
}
|
||||
},
|
||||
[timeFrom, timeTo, fetchHistory],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedTarget,
|
||||
trendData,
|
||||
trendLoading,
|
||||
historyData,
|
||||
historyLoading,
|
||||
timeFrom,
|
||||
timeTo,
|
||||
openModal,
|
||||
closeModal,
|
||||
handleTimeChange,
|
||||
handlePageChange,
|
||||
};
|
||||
}
|
||||
|
||||
function subtractHours(date: Date, hours: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setTime(result.getTime() - hours * 60 * 60 * 1000);
|
||||
return result;
|
||||
}
|
||||
@@ -6,25 +6,30 @@ export function useTrend(targetId: number | null) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTrend = useCallback(async () => {
|
||||
if (targetId === null) return;
|
||||
const fetchTrend = useCallback(
|
||||
async (from: string, to: string) => {
|
||||
if (targetId === null) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/targets/${targetId}/trend?hours=24`);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/targets/${targetId}/trend?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const result = (await response.json()) as TrendPoint[];
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [targetId]);
|
||||
const result = (await response.json()) as TrendPoint[];
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "请求失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[targetId],
|
||||
);
|
||||
|
||||
return { data, error, loading, fetchTrend };
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ body {
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
max-width: 1100px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ body {
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@@ -88,82 +88,89 @@ body {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.card-latency .card-value {
|
||||
color: #356dd2;
|
||||
.target-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.target-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
.target-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
color: #61728a;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.target-card {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(34, 57, 91, 0.08);
|
||||
}
|
||||
|
||||
.target-table thead th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #61728a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
background: rgba(236, 243, 252, 0.5);
|
||||
}
|
||||
|
||||
.target-row {
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 2px 8px rgba(34, 57, 91, 0.06);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
transition:
|
||||
box-shadow 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.target-row:hover {
|
||||
background: rgba(236, 243, 252, 0.6);
|
||||
.target-card:hover {
|
||||
box-shadow: 0 6px 24px rgba(34, 57, 91, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.target-row.expanded {
|
||||
background: rgba(236, 243, 252, 0.5);
|
||||
.card-row-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.target-row td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.06);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.col-status {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.col-target {
|
||||
color: #61728a;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
max-width: 260px;
|
||||
font-size: 0.9rem;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
.card-type-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(53, 109, 210, 0.1);
|
||||
color: #356dd2;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.col-duration {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.col-sparkline {
|
||||
width: 100px;
|
||||
.card-row-2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -171,6 +178,7 @@ body {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-up {
|
||||
@@ -183,75 +191,288 @@ body {
|
||||
box-shadow: 0 0 0 6px rgba(229, 72, 77, 0.14);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-bar-block {
|
||||
width: 6px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.status-bar-up {
|
||||
background: #1fbf75;
|
||||
}
|
||||
|
||||
.status-bar-down {
|
||||
background: #e5484d;
|
||||
}
|
||||
|
||||
.status-bar-empty {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.sparkline-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-cell {
|
||||
padding: 0 !important;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1) !important;
|
||||
background: rgba(240, 246, 252, 0.6);
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(16, 32, 51, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.target-detail {
|
||||
padding: 20px 24px;
|
||||
.modal-content {
|
||||
width: 80vw;
|
||||
max-height: 85vh;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 48px rgba(16, 32, 51, 0.2);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
}
|
||||
|
||||
.detail-stat {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
.modal-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #61728a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-stat-value {
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-up {
|
||||
color: #1fbf75;
|
||||
.modal-close-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1.5rem;
|
||||
color: #61728a;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-down {
|
||||
color: #e5484d;
|
||||
.modal-close-btn:hover {
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.detail-trend {
|
||||
margin-bottom: 20px;
|
||||
.time-range-picker {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(49, 83, 126, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-trend h4,
|
||||
.detail-history h4 {
|
||||
.time-shortcuts {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-shortcut-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #42546c;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.time-shortcut-btn:hover {
|
||||
background: rgba(53, 109, 210, 0.08);
|
||||
}
|
||||
|
||||
.time-shortcut-btn.active {
|
||||
background: #356dd2;
|
||||
color: #fff;
|
||||
border-color: #356dd2;
|
||||
}
|
||||
|
||||
.time-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(49, 83, 126, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
color: #61728a;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 24px;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-charts {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-chart-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-chart-section h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
.modal-history-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-history-section h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
color: #42546c;
|
||||
}
|
||||
|
||||
.status-donut {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.donut-center-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -60%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-loading,
|
||||
.trend-empty {
|
||||
.trend-empty,
|
||||
.history-loading,
|
||||
.history-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-history {
|
||||
margin-top: 16px;
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #d1d9e6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.history-table thead {
|
||||
background: #f0f4fa;
|
||||
}
|
||||
|
||||
.history-table th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #61728a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid #d1d9e6;
|
||||
}
|
||||
|
||||
.history-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
}
|
||||
|
||||
.history-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-table tbody tr:hover {
|
||||
background: rgba(236, 243, 252, 0.6);
|
||||
}
|
||||
|
||||
.ht-col-status {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.ht-col-time {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.ht-col-detail {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.ht-col-latency {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ht-col-error {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ht-status {
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.ht-time {
|
||||
color: #61728a;
|
||||
}
|
||||
|
||||
.ht-detail {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: #42546c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ht-latency {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #356dd2;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ht-error {
|
||||
color: #e5484d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
@@ -269,6 +490,14 @@ body {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.text-up {
|
||||
color: #1fbf75;
|
||||
}
|
||||
|
||||
.text-down {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
color: #61728a;
|
||||
}
|
||||
@@ -288,20 +517,48 @@ body {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.table-loading,
|
||||
.table-empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
.pagination-btn {
|
||||
border: 1px solid rgba(49, 83, 126, 0.2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #42546c;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: rgba(53, 109, 210, 0.08);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: #356dd2;
|
||||
color: #fff;
|
||||
border-color: #356dd2;
|
||||
}
|
||||
|
||||
.pagination-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
color: #94a3b8;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(49, 83, 126, 0.12);
|
||||
border-radius: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -313,16 +570,25 @@ body {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.col-type,
|
||||
.col-sparkline {
|
||||
display: none;
|
||||
.target-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.target-row td {
|
||||
padding: 10px 12px;
|
||||
.modal-content {
|
||||
width: 95vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-charts {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.time-range-picker {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
|
||||
import { ProbeStore } from "../../src/server/checker/store";
|
||||
import type { SummaryResponse, TargetStatus, HealthResponse } from "../../src/shared/api";
|
||||
import type { HistoryResponse, SummaryResponse, TargetStatus, HealthResponse } from "../../src/shared/api";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -28,6 +28,7 @@ describe("API 路由", () => {
|
||||
{
|
||||
type: "http",
|
||||
name: "test-a",
|
||||
group: "default",
|
||||
http: {
|
||||
url: "http://a.com",
|
||||
method: "GET",
|
||||
@@ -40,6 +41,7 @@ describe("API 路由", () => {
|
||||
{
|
||||
type: "command",
|
||||
name: "test-b",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
@@ -104,7 +106,7 @@ describe("API 路由", () => {
|
||||
expect(body.up).toBeGreaterThanOrEqual(0);
|
||||
expect(body.down).toBeGreaterThanOrEqual(0);
|
||||
expect(body.up + body.down).toBe(2);
|
||||
expect(body.avgDurationMs).toBeDefined();
|
||||
expect(body.lastCheckTime).not.toBeNull();
|
||||
});
|
||||
|
||||
test("/api/targets 返回目标列表", async () => {
|
||||
@@ -117,14 +119,15 @@ describe("API 路由", () => {
|
||||
const tA = body.find((t) => t.name === "test-a")!;
|
||||
expect(tA.type).toBe("http");
|
||||
expect(tA.target).toBe("http://a.com");
|
||||
expect(tA.group).toBe("default");
|
||||
expect(tA.latestCheck).not.toBeNull();
|
||||
expect(tA.latestCheck!.success).toBe(false);
|
||||
expect(tA.latestCheck!.matched).toBe(false);
|
||||
expect(tA.latestCheck!.failure).not.toBeNull();
|
||||
expect(tA.sparkline).toBeDefined();
|
||||
expect(Array.isArray(tA.sparkline)).toBe(true);
|
||||
expect(tA.stats.avgDurationMs).toBeDefined();
|
||||
expect(tA.stats.p99DurationMs).toBeDefined();
|
||||
expect(tA.recentSamples).toBeDefined();
|
||||
expect(Array.isArray(tA.recentSamples)).toBe(true);
|
||||
expect(tA.stats.totalChecks).toBeDefined();
|
||||
expect(tA.stats.availability).toBeDefined();
|
||||
|
||||
const tB = body.find((t) => t.name === "test-b")!;
|
||||
expect(tB.type).toBe("command");
|
||||
@@ -134,27 +137,43 @@ describe("API 路由", () => {
|
||||
|
||||
test("/api/targets/:id/history 返回历史记录", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
|
||||
const body = await response.json();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}`),
|
||||
);
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0].failure).not.toBeNull();
|
||||
expect(body[0].failure.kind).toBe("error");
|
||||
expect(body.items).toHaveLength(2);
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.page).toBe(1);
|
||||
expect(body.pageSize).toBe(20);
|
||||
expect(body.items[0]!.failure).not.toBeNull();
|
||||
expect(body.items[0]!.failure!.kind).toBe("error");
|
||||
});
|
||||
|
||||
test("/api/targets/:id/history 支持 limit 参数", async () => {
|
||||
test("/api/targets/:id/history 支持 page 参数", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=1`));
|
||||
const body = await response.json();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=1`),
|
||||
);
|
||||
const body = (await response.json()) as HistoryResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
test("/api/targets/:id/trend 返回趋势数据", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/trend?from=${from}&to=${to}`),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -162,22 +181,33 @@ describe("API 路由", () => {
|
||||
});
|
||||
|
||||
test("查询不存在的目标返回 404", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/targets/99999/history"));
|
||||
const response = await fetchHandler(
|
||||
new Request(
|
||||
"http://localhost/api/targets/99999/history?from=2024-01-01T00:00:00.000Z&to=2026-12-31T23:59:59.999Z",
|
||||
),
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe("Target not found");
|
||||
});
|
||||
|
||||
test("无效 limit 参数返回 400", async () => {
|
||||
test("history 缺少 from/to 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=abc`),
|
||||
);
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("Invalid limit parameter");
|
||||
expect(body.error).toContain("from and to");
|
||||
});
|
||||
|
||||
test("trend 缺少 from/to 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toContain("from and to");
|
||||
});
|
||||
|
||||
test("无效目标 ID 返回 400", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ function makeTarget(
|
||||
return {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
|
||||
@@ -479,4 +479,53 @@ targets:
|
||||
expect(t.command.env.PATH).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("解析 group 字段", async () => {
|
||||
const configPath = join(tempDir, "group.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "grouped"
|
||||
type: http
|
||||
group: "搜索引擎"
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets[0]!.group).toBe("搜索引擎");
|
||||
});
|
||||
|
||||
test("group 字段默认为 default", async () => {
|
||||
const configPath = join(tempDir, "no-group.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "no-group"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets[0]!.group).toBe("default");
|
||||
});
|
||||
|
||||
test("非法 group 类型抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-group.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
type: http
|
||||
group: 123
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("group 字段必须为字符串");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ function createMockStore(targetNames: string[]) {
|
||||
interval_ms: 60000,
|
||||
timeout_ms: 5000,
|
||||
expect: null,
|
||||
grp: "default",
|
||||
}));
|
||||
},
|
||||
insertCheckResult(result: Record<string, unknown>) {
|
||||
@@ -32,6 +33,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
|
||||
return {
|
||||
type: "command",
|
||||
name,
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "echo",
|
||||
args: ["hello"],
|
||||
@@ -175,6 +177,7 @@ describe("ProbeEngine", () => {
|
||||
const httpTarget: ResolvedHttpTarget = {
|
||||
type: "http",
|
||||
name: "http-test",
|
||||
group: "default",
|
||||
http: {
|
||||
url: `http://localhost:${httpServer.port}/`,
|
||||
method: "GET",
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("runHttpCheck 集成", () => {
|
||||
return {
|
||||
type: "http" as const,
|
||||
name: "test-http",
|
||||
group: "default",
|
||||
http: {
|
||||
url: overrides.url ?? `${baseUrl}/ok`,
|
||||
method: overrides.method ?? "GET",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { tmpdir } from "node:os";
|
||||
const httpTarget: ResolvedTarget = {
|
||||
type: "http",
|
||||
name: "test-http",
|
||||
group: "default",
|
||||
http: {
|
||||
url: "https://example.com/health",
|
||||
method: "GET",
|
||||
@@ -22,6 +23,7 @@ const httpTarget: ResolvedTarget = {
|
||||
const commandTarget: ResolvedTarget = {
|
||||
type: "command",
|
||||
name: "test-cmd",
|
||||
group: "default",
|
||||
command: {
|
||||
exec: "ping",
|
||||
args: ["-c", "1", "localhost"],
|
||||
@@ -166,9 +168,9 @@ describe("ProbeStore", () => {
|
||||
failure,
|
||||
});
|
||||
|
||||
const history = store.getHistory(t1Id, 10);
|
||||
expect(history).toHaveLength(3);
|
||||
expect(history[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z");
|
||||
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z", 1, 10);
|
||||
expect(history.items).toHaveLength(3);
|
||||
expect(history.items[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z");
|
||||
|
||||
const latest = store.getLatestCheck(t1Id)!;
|
||||
expect(latest.success).toBe(0);
|
||||
@@ -195,8 +197,8 @@ describe("ProbeStore", () => {
|
||||
});
|
||||
}
|
||||
|
||||
const history = store.getHistory(t1Id);
|
||||
expect(history).toHaveLength(20);
|
||||
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z");
|
||||
expect(history.items).toHaveLength(20);
|
||||
});
|
||||
|
||||
test("getTargetStats 计算可用率和 duration", () => {
|
||||
@@ -207,8 +209,6 @@ describe("ProbeStore", () => {
|
||||
expect(stats.totalChecks).toBeGreaterThan(0);
|
||||
expect(stats.availability).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.availability).toBeLessThanOrEqual(100);
|
||||
expect(stats.avgDurationMs).not.toBeNull();
|
||||
expect(typeof stats.avgDurationMs).toBe("number");
|
||||
});
|
||||
|
||||
test("无记录目标的 stats", () => {
|
||||
@@ -218,8 +218,6 @@ describe("ProbeStore", () => {
|
||||
const stats = store.getTargetStats(t2Id);
|
||||
expect(stats.totalChecks).toBe(0);
|
||||
expect(stats.availability).toBe(0);
|
||||
expect(stats.avgDurationMs).toBeNull();
|
||||
expect(stats.p99DurationMs).toBeNull();
|
||||
});
|
||||
|
||||
test("getSummary 返回总览统计", () => {
|
||||
@@ -227,14 +225,13 @@ describe("ProbeStore", () => {
|
||||
expect(summary.total).toBe(2);
|
||||
expect(summary.up + summary.down).toBe(2);
|
||||
expect(summary.lastCheckTime).not.toBeNull();
|
||||
expect(summary.avgDurationMs).not.toBeNull();
|
||||
});
|
||||
|
||||
test("getTrend 返回趋势数据", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
const trend = store.getTrend(t1Id, 24);
|
||||
const trend = store.getTrend(t1Id, "2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z");
|
||||
expect(Array.isArray(trend)).toBe(true);
|
||||
if (trend.length > 0) {
|
||||
expect(trend[0]!.hour).toBeDefined();
|
||||
@@ -244,15 +241,17 @@ describe("ProbeStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("getSparkline 返回 duration 数组", () => {
|
||||
test("getRecentSamples 返回最近采样数据", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
const sparkline = store.getSparkline(t1Id);
|
||||
expect(Array.isArray(sparkline)).toBe(true);
|
||||
expect(sparkline.length).toBeGreaterThan(0);
|
||||
for (const val of sparkline) {
|
||||
expect(typeof val).toBe("number");
|
||||
const samples = store.getRecentSamples(t1Id, 10);
|
||||
expect(Array.isArray(samples)).toBe(true);
|
||||
expect(samples.length).toBeGreaterThan(0);
|
||||
for (const sample of samples) {
|
||||
expect(typeof sample.timestamp).toBe("string");
|
||||
expect(typeof sample.success).toBe("number");
|
||||
expect(typeof sample.matched).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user