diff --git a/README.md b/README.md
index 20fa2f6..b69d307 100644
--- a/README.md
+++ b/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
diff --git a/openspec/config.yaml b/openspec/config.yaml
index 049f339..1f1f27f 100644
--- a/openspec/config.yaml
+++ b/openspec/config.yaml
@@ -11,6 +11,7 @@ context: |
- 禁止创建git操作task
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
- 优先使用提问工具对用户进行提问
+ - (当前项目未上线,不需要考虑向前兼容)
rules:
proposal:
diff --git a/openspec/specs/card-dashboard/spec.md b/openspec/specs/card-dashboard/spec.md
new file mode 100644
index 0000000..1e7b0e2
--- /dev/null
+++ b/openspec/specs/card-dashboard/spec.md
@@ -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 打开该目标的详情模态框
diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md
index 02c5262..8005be7 100644
--- a/openspec/specs/probe-api/spec.md
+++ b/openspec/specs/probe-api/spec.md
@@ -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 契约
diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md
index 1a99023..3195c35 100644
--- a/openspec/specs/probe-config/spec.md
+++ b/openspec/specs/probe-config/spec.md
@@ -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 以错误退出并提示格式错误
diff --git a/openspec/specs/probe-dashboard/spec.md b/openspec/specs/probe-dashboard/spec.md
index c963d86..cb1dd89 100644
--- a/openspec/specs/probe-dashboard/spec.md
+++ b/openspec/specs/probe-dashboard/spec.md
@@ -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 显示加载指示
diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md
index b69fc8b..b057e39 100644
--- a/openspec/specs/probe-data-store/spec.md
+++ b/openspec/specs/probe-data-store/spec.md
@@ -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 耗时等统计指标。
diff --git a/openspec/specs/target-detail-modal/spec.md b/openspec/specs/target-detail-modal/spec.md
new file mode 100644
index 0000000..166b237
--- /dev/null
+++ b/openspec/specs/target-detail-modal/spec.md
@@ -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 分为上下两部分,上方展示统计图表,下方展示检查结果列表和分页器
diff --git a/openspec/specs/target-grouping/spec.md b/openspec/specs/target-grouping/spec.md
new file mode 100644
index 0000000..8a9d219
--- /dev/null
+++ b/openspec/specs/target-grouping/spec.md
@@ -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"`
diff --git a/probes.example.yaml b/probes.example.yaml
index f784474..2460ab6 100644
--- a/probes.example.yaml
+++ b/probes.example.yaml
@@ -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"]
diff --git a/scripts/build.ts b/scripts/build.ts
index 3e7e2c7..afb52e9 100644
--- a/scripts/build.ts
+++ b/scripts/build.ts
@@ -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({
diff --git a/scripts/smoke.ts b/scripts/smoke.ts
index 54acb21..20326cc 100644
--- a/scripts/smoke.ts
+++ b/scripts/smoke.ts
@@ -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",
diff --git a/src/server/app.ts b/src/server/app.ts
index 11962b6..4a61236 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -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,
},
};
});
diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts
index f814e0e..78f4735 100644
--- a/src/server/checker/config-loader.ts
+++ b/src/server/checker/config-loader.ts
@@ -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}"`);
}
diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts
index 7cc3284..2483360 100644
--- a/src/server/checker/store.ts
+++ b/src/server/checker/store.ts
@@ -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 {
diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts
index cb53ac8..1d460fc 100644
--- a/src/server/checker/types.ts
+++ b/src/server/checker/types.ts
@@ -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 {
diff --git a/src/shared/api.ts b/src/shared/api.ts
index e4c7b9c..3a79853 100644
--- a/src/shared/api.ts
+++ b/src/shared/api.ts
@@ -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 {
diff --git a/src/web/app.tsx b/src/web/app.tsx
index 67c88a9..099ddd2 100644
--- a/src/web/app.tsx
+++ b/src/web/app.tsx
@@ -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 (
加载中...
- ) : history.length > 0 ? ( -暂无检查记录
- )} -| 状态 | +时间 | +详情 | +耗时 | +错误信息 | +
|---|---|---|---|---|
| + + {item.success && item.matched ? "UP" : "DOWN"} + + | +{new Date(item.timestamp).toLocaleString("zh-CN")} | +{item.statusDetail ?? "-"} | ++ {item.durationMs !== null ? `${Math.round(item.durationMs)}ms` : "-"} + | +{item.failure?.message ?? ""} | +
| 状态 | -名称 | -目标 | -类型 | -耗时 | -趋势 | -
|---|