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 (
@@ -19,7 +46,22 @@ export function App() { {error &&
请求失败: {error},将在下一次轮询周期自动重试
} - + + + {selectedTarget && ( + + )}
); } diff --git a/src/web/components/CardGrid.tsx b/src/web/components/CardGrid.tsx new file mode 100644 index 0000000..813da0a --- /dev/null +++ b/src/web/components/CardGrid.tsx @@ -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 ( +
+ {targets.map((target) => ( + onTargetClick(target)} /> + ))} +
+ ); +} diff --git a/src/web/components/GroupHeader.tsx b/src/web/components/GroupHeader.tsx new file mode 100644 index 0000000..25f4be8 --- /dev/null +++ b/src/web/components/GroupHeader.tsx @@ -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 ( +
+

{displayName}

+ + ({total}个, {up} UP / {down} DOWN) + +
+ ); +} diff --git a/src/web/components/MiniSparkline.tsx b/src/web/components/MiniSparkline.tsx new file mode 100644 index 0000000..0ceba87 --- /dev/null +++ b/src/web/components/MiniSparkline.tsx @@ -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 -; + } + + return ( + + + + + + ); +} diff --git a/src/web/components/Pagination.tsx b/src/web/components/Pagination.tsx new file mode 100644 index 0000000..812f7ca --- /dev/null +++ b/src/web/components/Pagination.tsx @@ -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 ( +
+ + {visiblePages.map((p, idx) => { + const prev = visiblePages[idx - 1]; + const showEllipsis = prev !== undefined && p - prev > 1; + return ( + + {showEllipsis && ...} + + + ); + })} + +
+ ); +} diff --git a/src/web/components/SparklineChart.tsx b/src/web/components/SparklineChart.tsx deleted file mode 100644 index 2b944de..0000000 --- a/src/web/components/SparklineChart.tsx +++ /dev/null @@ -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 -; - } - - return ( - - - - - - ); -} diff --git a/src/web/components/StatusBar.tsx b/src/web/components/StatusBar.tsx new file mode 100644 index 0000000..fc79b34 --- /dev/null +++ b/src/web/components/StatusBar.tsx @@ -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(); + } else { + blocks.push(); + } + } + + return
{blocks}
; +} diff --git a/src/web/components/StatusDonut.tsx b/src/web/components/StatusDonut.tsx new file mode 100644 index 0000000..77cc108 --- /dev/null +++ b/src/web/components/StatusDonut.tsx @@ -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 ( +
+ + + + {data.map((_, index) => ( + + ))} + + + +
{availability}%
+
+ ); +} diff --git a/src/web/components/SummaryCards.tsx b/src/web/components/SummaryCards.tsx index 3e1ead5..7a083d2 100644 --- a/src/web/components/SummaryCards.tsx +++ b/src/web/components/SummaryCards.tsx @@ -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 ( diff --git a/src/web/components/TargetBoard.tsx b/src/web/components/TargetBoard.tsx new file mode 100644 index 0000000..06765ab --- /dev/null +++ b/src/web/components/TargetBoard.tsx @@ -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(); + 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 ( +
+ {Array.from(groups.entries()).map(([name, groupTargets]) => ( + + ))} +
+ ); +} diff --git a/src/web/components/TargetCard.tsx b/src/web/components/TargetCard.tsx new file mode 100644 index 0000000..46bd298 --- /dev/null +++ b/src/web/components/TargetCard.tsx @@ -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 ( +
+
+ + {target.name} + {target.type === "http" ? "HTTP" : "Command"} +
+
+ + +
+
+ ); +} diff --git a/src/web/components/TargetDetail.tsx b/src/web/components/TargetDetail.tsx deleted file mode 100644 index af3316c..0000000 --- a/src/web/components/TargetDetail.tsx +++ /dev/null @@ -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([]); - 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 ( - - -
-
-
- 状态 - {isUp ? "UP" : "DOWN"} -
-
- 可用率 - - {stats.totalChecks > 0 ? `${stats.availability.toFixed(1)}%` : "-"} - -
-
- 平均耗时 - - {stats.avgDurationMs !== null ? `${Math.round(stats.avgDurationMs)}ms` : "-"} - -
-
- P99 耗时 - - {stats.p99DurationMs !== null ? `${Math.round(stats.p99DurationMs)}ms` : "-"} - -
-
- -
-

24 小时趋势

- -
- -
-

最近检查记录

- {historyLoading ? ( -

加载中...

- ) : history.length > 0 ? ( -
- {history.map((item, idx) => ( -
- - {item.success && item.matched ? "UP" : "DOWN"} - - {new Date(item.timestamp).toLocaleString("zh-CN")} - {item.statusDetail && {item.statusDetail}} - {item.durationMs !== null && ( - {Math.round(item.durationMs)}ms - )} - {item.failure?.message && {item.failure.message}} -
- ))} -
- ) : ( -

暂无检查记录

- )} -
-
- - - ); -} diff --git a/src/web/components/TargetDetailModal.tsx b/src/web/components/TargetDetailModal.tsx new file mode 100644 index 0000000..0e05254 --- /dev/null +++ b/src/web/components/TargetDetailModal.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+
+ +

{target.name}

+ {target.type === "http" ? "HTTP" : "Command"} +
+ +
+ + + +
+
+
+

状态分布

+ +
+
+

趋势图

+ +
+
+ +
+

检查记录

+ {historyLoading ? ( +
加载中...
+ ) : historyData.items.length > 0 ? ( + <> + + + + + + + + + + + + {historyData.items.map((item, idx) => ( + + + + + + + + ))} + +
状态时间详情耗时错误信息
+ + {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 ?? ""}
+ + + ) : ( +
暂无检查记录
+ )} +
+
+
+
+ ); +} diff --git a/src/web/components/TargetGroup.tsx b/src/web/components/TargetGroup.tsx new file mode 100644 index 0000000..1b1119a --- /dev/null +++ b/src/web/components/TargetGroup.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/web/components/TargetRow.tsx b/src/web/components/TargetRow.tsx deleted file mode 100644 index 808a02d..0000000 --- a/src/web/components/TargetRow.tsx +++ /dev/null @@ -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 ( - - - - - {target.name} - {target.target} - {target.type === "http" ? "HTTP" : "Command"} - - {target.latestCheck?.durationMs !== null && target.latestCheck?.durationMs !== undefined - ? `${Math.round(target.latestCheck.durationMs)}ms` - : "-"} - - - - - - ); -} diff --git a/src/web/components/TargetTable.tsx b/src/web/components/TargetTable.tsx deleted file mode 100644 index cb4d519..0000000 --- a/src/web/components/TargetTable.tsx +++ /dev/null @@ -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(null); - - if (loading && targets.length === 0) { - return
加载目标列表...
; - } - - if (targets.length === 0) { - return
暂无拨测目标
; - } - - return ( - - - - - - - - - - - - - {targets.map((target) => { - const isExpanded = expandedId === target.id; - return ( - setExpandedId(isExpanded ? null : target.id)} - /> - ); - })} - -
状态名称目标类型耗时趋势
- ); -} - -function TargetRowWrapper({ - target, - expanded, - onToggle, -}: { - target: TargetStatus; - expanded: boolean; - onToggle: () => void; -}) { - return ( - <> - - {expanded && } - - ); -} diff --git a/src/web/components/TimeRangePicker.tsx b/src/web/components/TimeRangePicker.tsx new file mode 100644 index 0000000..1ca53d6 --- /dev/null +++ b/src/web/components/TimeRangePicker.tsx @@ -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("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 ( +
+
+ {SHORTCUTS.map((s) => ( + + ))} +
+
+ handleFromChange(e.target.value)} + /> + ~ + handleToChange(e.target.value)} + /> +
+
+ ); +} diff --git a/src/web/hooks/useHistory.ts b/src/web/hooks/useHistory.ts new file mode 100644 index 0000000..a68f6ef --- /dev/null +++ b/src/web/hooks/useHistory.ts @@ -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({ items: [], total: 0, page: 1, pageSize: 15 }); + const [error, setError] = useState(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 }; +} diff --git a/src/web/hooks/useTargetDetail.ts b/src/web/hooks/useTargetDetail.ts new file mode 100644 index 0000000..bfb6960 --- /dev/null +++ b/src/web/hooks/useTargetDetail.ts @@ -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(null); + const [timeFrom, setTimeFrom] = useState(""); + const [timeTo, setTimeTo] = useState(""); + + 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; +} diff --git a/src/web/hooks/useTrend.ts b/src/web/hooks/useTrend.ts index 7e144bb..694e793 100644 --- a/src/web/hooks/useTrend.ts +++ b/src/web/hooks/useTrend.ts @@ -6,25 +6,30 @@ export function useTrend(targetId: number | null) { const [error, setError] = useState(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 }; } diff --git a/src/web/styles.css b/src/web/styles.css index 78955cb..3dc2fa2 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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; } } diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 85b9eec..53d1c20 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -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 () => { diff --git a/tests/server/checker/command-runner.test.ts b/tests/server/checker/command-runner.test.ts index 23f327f..2577519 100644 --- a/tests/server/checker/command-runner.test.ts +++ b/tests/server/checker/command-runner.test.ts @@ -9,6 +9,7 @@ function makeTarget( return { type: "command", name: "test-cmd", + group: "default", command: { exec: "echo", args: ["hello"], diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 4f412fd..f8534b1 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -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 字段必须为字符串"); + }); }); diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index 80c6a67..9e4eb26 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -19,6 +19,7 @@ function createMockStore(targetNames: string[]) { interval_ms: 60000, timeout_ms: 5000, expect: null, + grp: "default", })); }, insertCheckResult(result: Record) { @@ -32,6 +33,7 @@ function makeCommandTarget(name: string, overrides?: Partial { const httpTarget: ResolvedHttpTarget = { type: "http", name: "http-test", + group: "default", http: { url: `http://localhost:${httpServer.port}/`, method: "GET", diff --git a/tests/server/checker/fetcher.test.ts b/tests/server/checker/fetcher.test.ts index 9e7c312..71bbe75 100644 --- a/tests/server/checker/fetcher.test.ts +++ b/tests/server/checker/fetcher.test.ts @@ -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", diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index 4b949df..9d19d13 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -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"); } });