feat: 新增 target description 字段,收紧 id/name 长度,调整延迟列和名称列
This commit is contained in:
@@ -463,7 +463,7 @@ TcpChecker implements Checker
|
|||||||
|
|
||||||
**Schema**:
|
**Schema**:
|
||||||
|
|
||||||
- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(展示名称)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp
|
- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(展示名称)、description(描述)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp
|
||||||
- `check_results` 表:target_id(TEXT FK CASCADE,引用配置 target id)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
- `check_results` 表:target_id(TEXT FK CASCADE,引用配置 target id)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
||||||
- 复合索引:`(target_id, timestamp)`
|
- 复合索引:`(target_id, timestamp)`
|
||||||
|
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -176,14 +176,15 @@ targets:
|
|||||||
|
|
||||||
每个 target 的通用字段:
|
每个 target 的通用字段:
|
||||||
|
|
||||||
| 字段 | 说明 | 必填 |
|
| 字段 | 说明 | 必填 |
|
||||||
| ---------- | ---------------------------------------------------------- | -------------------- |
|
| ------------- | ------------------------------------------------------------------------ | -------------------- |
|
||||||
| `id` | 目标唯一标识,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
|
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
|
||||||
| `name` | 展示名称,支持变量替换;省略时使用 `id` | 否 |
|
| `name` | 展示名称,最长 30 字符,支持变量替换;省略时使用 `id` | 否 |
|
||||||
| `type` | 目标类型:`http`、`cmd`、`db` | 是 |
|
| `description` | 目标描述,最长 500 字符,支持变量替换,允许空字符串 | 否 |
|
||||||
| `group` | 分组名称 | 否,默认 `"default"` |
|
| `type` | 目标类型:`http`、`cmd`、`db` | 是 |
|
||||||
| `interval` | 覆盖全局拨测间隔 | 否 |
|
| `group` | 分组名称 | 否,默认 `"default"` |
|
||||||
| `timeout` | 覆盖全局超时时间 | 否 |
|
| `interval` | 覆盖全局拨测间隔 | 否 |
|
||||||
|
| `timeout` | 覆盖全局超时时间 | 否 |
|
||||||
|
|
||||||
**HTTP 类型** (`type: http`)
|
**HTTP 类型** (`type: http`)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
|
|
||||||
#### Scenario: targets 字段
|
#### Scenario: targets 字段
|
||||||
- **WHEN** Dashboard 响应包含 targets
|
- **WHEN** Dashboard 响应包含 targets
|
||||||
- **THEN** targets 数组中每个元素 SHALL 包含目标基本信息(id、name、group、type、target、interval)、latestCheck、stats、currentStreak 和 recentSamples 字段
|
- **THEN** targets 数组中每个元素 SHALL 包含目标基本信息(id、name、description、group、type、target、interval)、latestCheck、stats、currentStreak 和 recentSamples 字段,其中 description 为 null 或字符串
|
||||||
|
|
||||||
|
#### Scenario: target description 字段
|
||||||
|
- **WHEN** 某个 target 配置了 `description`
|
||||||
|
- **THEN** Dashboard targets 响应中对应元素 SHALL 返回该 description 值
|
||||||
|
|
||||||
#### Scenario: window 参数缺失
|
#### Scenario: window 参数缺失
|
||||||
- **WHEN** 客户端请求 `GET /api/dashboard` 未提供 window 参数
|
- **WHEN** 客户端请求 `GET /api/dashboard` 未提供 window 参数
|
||||||
@@ -105,7 +109,7 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
|
|||||||
|
|
||||||
#### Scenario: TargetStatus 类型
|
#### Scenario: TargetStatus 类型
|
||||||
- **WHEN** 前后端共享 `TargetStatus` 类型
|
- **WHEN** 前后端共享 `TargetStatus` 类型
|
||||||
- **THEN** 该类型 SHALL 包含 stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段
|
- **THEN** 该类型 SHALL 包含目标基本信息字段(id、name、description、group、type、target、interval)、stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段,其中 description 类型为 null 或字符串
|
||||||
|
|
||||||
#### Scenario: TargetMetricsResponse 类型
|
#### Scenario: TargetMetricsResponse 类型
|
||||||
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型
|
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型
|
||||||
|
|||||||
@@ -328,3 +328,45 @@
|
|||||||
#### Scenario: dataDir 使用默认值
|
#### Scenario: dataDir 使用默认值
|
||||||
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`)
|
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`)
|
||||||
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径
|
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径
|
||||||
|
|
||||||
|
### Requirement: target 通用元信息字段约束
|
||||||
|
系统 SHALL 在 YAML target 通用字段中支持 `description` 字段,并对 `id`、`name` 和 `description` 执行契约校验。`id` MUST 为 1 到 30 个字符,显式 `name` MUST 为 1 到 30 个字符,`description` MUST 不超过 500 个字符且 MAY 为空字符串。
|
||||||
|
|
||||||
|
#### Scenario: description 字段解析
|
||||||
|
- **WHEN** 系统读取包含 `description: "检查生产 API 健康状态"` 的 target
|
||||||
|
- **THEN** 系统 SHALL 将该字段解析为 target 的目标说明
|
||||||
|
|
||||||
|
#### Scenario: description 为空字符串
|
||||||
|
- **WHEN** 系统读取包含 `description: ""` 的 target
|
||||||
|
- **THEN** 系统 SHALL 接受该配置,且不触发长度错误
|
||||||
|
|
||||||
|
#### Scenario: description 类型非法
|
||||||
|
- **WHEN** YAML 中某个 target 的 `description` 字段不是字符串
|
||||||
|
- **THEN** 系统 SHALL 以错误退出,提示 description 字段类型错误
|
||||||
|
|
||||||
|
#### Scenario: description 超过最大长度
|
||||||
|
- **WHEN** YAML 中某个 target 的 `description` 字段超过 500 个字符
|
||||||
|
- **THEN** 系统 SHALL 以错误退出,提示 description 字段长度错误
|
||||||
|
|
||||||
|
#### Scenario: id 超过最大长度
|
||||||
|
- **WHEN** YAML 中某个 target 的 `id` 字段超过 30 个字符
|
||||||
|
- **THEN** 系统 SHALL 以错误退出,提示 id 字段长度错误
|
||||||
|
|
||||||
|
#### Scenario: name 超过最大长度
|
||||||
|
- **WHEN** YAML 中某个 target 的 `name` 字段超过 30 个字符
|
||||||
|
- **THEN** 系统 SHALL 以错误退出,提示 name 字段长度错误
|
||||||
|
|
||||||
|
#### Scenario: 变量替换后 description 超长
|
||||||
|
- **WHEN** target 的 `description` 通过变量替换后超过 500 个字符
|
||||||
|
- **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误
|
||||||
|
|
||||||
|
### Requirement: 配置 schema 导出包含 target 元信息约束
|
||||||
|
系统 SHALL 在导出的 `probe-config.schema.json` 中包含 target `id`、`name` 和 `description` 的长度约束,用于编辑器提示和外部校验。
|
||||||
|
|
||||||
|
#### Scenario: schema 导出 description
|
||||||
|
- **WHEN** 系统导出 `probe-config.schema.json`
|
||||||
|
- **THEN** target schema SHALL 包含可选的 `description` 字段,类型为 string,最大长度为 500
|
||||||
|
|
||||||
|
#### Scenario: schema 导出 id 和 name 长度
|
||||||
|
- **WHEN** 系统导出 `probe-config.schema.json`
|
||||||
|
- **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30,并声明 `name` 的 minLength 为 1、maxLength 为 30
|
||||||
|
|||||||
@@ -26,15 +26,19 @@
|
|||||||
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
|
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
|
||||||
|
|
||||||
### Requirement: targets 表同步
|
### Requirement: targets 表同步
|
||||||
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。
|
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。
|
||||||
|
|
||||||
#### Scenario: 首次同步目标
|
#### Scenario: 首次同步目标
|
||||||
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
|
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
|
||||||
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms、expect 和 grp
|
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect 和 grp
|
||||||
|
|
||||||
#### Scenario: 配置变更后重新同步
|
#### Scenario: 配置变更后重新同步
|
||||||
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
|
||||||
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
|
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 description 和 grp 字段)
|
||||||
|
|
||||||
|
#### Scenario: 未配置 description
|
||||||
|
- **WHEN** YAML target 未配置 `description`
|
||||||
|
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
|
||||||
|
|
||||||
### Requirement: check_results 表追加写入
|
### Requirement: check_results 表追加写入
|
||||||
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。
|
||||||
|
|||||||
@@ -215,7 +215,11 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
|
|||||||
|
|
||||||
#### Scenario: 基本信息内容
|
#### Scenario: 基本信息内容
|
||||||
- **WHEN** 概览面板渲染
|
- **WHEN** 概览面板渲染
|
||||||
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情
|
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情、描述,其中描述 SHALL 位于最后一行
|
||||||
|
|
||||||
|
#### Scenario: 描述行占满整行
|
||||||
|
- **WHEN** 概览面板渲染基本信息
|
||||||
|
- **THEN** 描述项 SHALL 占据 Descriptions 的一整行,内容 SHALL 使用 `target.description ?? ""`,即使 description 为空也 SHALL 渲染该项
|
||||||
|
|
||||||
#### Scenario: 统计区上下布局卡片
|
#### Scenario: 统计区上下布局卡片
|
||||||
- **WHEN** 概览面板渲染且有统计数据
|
- **WHEN** 概览面板渲染且有统计数据
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: target id 字段
|
### Requirement: target id 字段
|
||||||
每个 target SHALL 包含必填的 `id` 字段作为唯一标识符。`id` SHALL 符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则。`id` MUST 在所有 targets 中全局唯一。`id` MUST NOT 参与变量替换。
|
每个 target SHALL 包含必填的 `id` 字段作为唯一标识符。`id` SHALL 符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则。`id` 长度 MUST 为 1 到 30 个字符。`id` MUST 在所有 targets 中全局唯一。`id` MUST NOT 参与变量替换。
|
||||||
|
|
||||||
#### Scenario: 合法 id
|
#### Scenario: 合法 id
|
||||||
- **WHEN** target 配置 `id: "api-health"`
|
- **WHEN** target 配置 `id: "api-health"`
|
||||||
@@ -23,6 +23,10 @@
|
|||||||
- **WHEN** target 配置 `id: ""`
|
- **WHEN** target 配置 `id: ""`
|
||||||
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空
|
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空
|
||||||
|
|
||||||
|
#### Scenario: id 超过最大长度报错
|
||||||
|
- **WHEN** target 配置超过 30 个字符的 `id`
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 id 长度不合法
|
||||||
|
|
||||||
#### Scenario: id 不合法报错
|
#### Scenario: id 不合法报错
|
||||||
- **WHEN** target 配置 `id: "_invalid"` 或 `id: "-start"` 或 `id: "has space"`
|
- **WHEN** target 配置 `id: "_invalid"` 或 `id: "-start"` 或 `id: "has space"`
|
||||||
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则
|
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则
|
||||||
@@ -32,7 +36,7 @@
|
|||||||
- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复
|
- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复
|
||||||
|
|
||||||
### Requirement: target name 字段
|
### Requirement: target name 字段
|
||||||
每个 target SHALL 支持可选的 `name` 字段作为展示名称。`name` 缺省时 SHALL fallback 到 `id` 的值作为展示名称。`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一。
|
每个 target SHALL 支持可选的 `name` 字段作为展示名称。`name` 缺省时 SHALL fallback 到 `id` 的值作为展示名称。显式配置的 `name` 长度 MUST 为 1 到 30 个字符。`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一。
|
||||||
|
|
||||||
#### Scenario: 配置 name
|
#### Scenario: 配置 name
|
||||||
- **WHEN** target 配置 `id: "api-health"` 和 `name: "API 健康检查"`
|
- **WHEN** target 配置 `id: "api-health"` 和 `name: "API 健康检查"`
|
||||||
@@ -46,6 +50,37 @@
|
|||||||
- **WHEN** target 配置 `id: "api-health"` 但未配置 `name`
|
- **WHEN** target 配置 `id: "api-health"` 但未配置 `name`
|
||||||
- **THEN** 系统 SHALL 使用 "api-health" 作为展示名称
|
- **THEN** 系统 SHALL 使用 "api-health" 作为展示名称
|
||||||
|
|
||||||
|
#### Scenario: name 为空字符串报错
|
||||||
|
- **WHEN** target 配置 `name: ""`
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法
|
||||||
|
|
||||||
|
#### Scenario: name 超过最大长度报错
|
||||||
|
- **WHEN** target 配置超过 30 个字符的 `name`
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法
|
||||||
|
|
||||||
#### Scenario: 多个 target 使用相同 name
|
#### Scenario: 多个 target 使用相同 name
|
||||||
- **WHEN** 两个 target 配置不同 id 但相同 `name: "健康检查"`
|
- **WHEN** 两个 target 配置不同 id 但相同 `name: "健康检查"`
|
||||||
- **THEN** 系统 SHALL 接受该配置,不报错(name 不要求全局唯一)
|
- **THEN** 系统 SHALL 接受该配置,不报错(name 不要求全局唯一)
|
||||||
|
|
||||||
|
### Requirement: target description 字段
|
||||||
|
每个 target SHALL 支持可选的 `description` 字段作为目标说明。`description` SHALL 支持变量替换。`description` 长度 MUST 不超过 500 个字符,且允许为空字符串。`description` MUST NOT 参与 target 唯一性判定。
|
||||||
|
|
||||||
|
#### Scenario: 配置 description
|
||||||
|
- **WHEN** target 配置 `description: "检查生产 API 健康状态"`
|
||||||
|
- **THEN** 系统 SHALL 使用该值作为目标说明
|
||||||
|
|
||||||
|
#### Scenario: description 使用变量
|
||||||
|
- **WHEN** target 配置 `description: "${env} 环境健康检查"` 且 variables 中 `env: "生产"`
|
||||||
|
- **THEN** 系统 SHALL 将目标说明解析为 "生产 环境健康检查"
|
||||||
|
|
||||||
|
#### Scenario: description 缺省
|
||||||
|
- **WHEN** target 未配置 `description`
|
||||||
|
- **THEN** 系统 SHALL 接受该配置,且目标说明为 null
|
||||||
|
|
||||||
|
#### Scenario: description 为空字符串
|
||||||
|
- **WHEN** target 配置 `description: ""`
|
||||||
|
- **THEN** 系统 SHALL 接受该配置,且目标说明为空字符串
|
||||||
|
|
||||||
|
#### Scenario: description 超过最大长度报错
|
||||||
|
- **WHEN** target 配置超过 500 个字符的 `description`
|
||||||
|
- **THEN** 系统 SHALL 以配置错误退出,提示 description 长度不合法
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
|
|||||||
|
|
||||||
#### Scenario: 名称列
|
#### Scenario: 名称列
|
||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
- **THEN** 名称列 SHALL 显示目标名称,支持字母排序(zh-CN),ellipsis 超长名称自动省略并 Tooltip 显示全名
|
- **THEN** 名称列 SHALL 显示目标名称,ellipsis 超长名称自动省略并 Tooltip 显示全名,且 SHALL NOT 支持排序
|
||||||
|
|
||||||
#### Scenario: 类型列
|
#### Scenario: 类型列
|
||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
@@ -68,7 +68,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
|
|||||||
|
|
||||||
#### Scenario: 延迟列
|
#### Scenario: 延迟列
|
||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ms"
|
- **THEN** 延迟列标题 SHALL 展示为"延迟(ms)",单元格 SHALL 显示最近一次检查的延迟毫秒数值并右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+"
|
||||||
|
|
||||||
#### Scenario: 间隔列移除
|
#### Scenario: 间隔列移除
|
||||||
- **WHEN** 表格渲染
|
- **WHEN** 表格渲染
|
||||||
|
|||||||
@@ -109,6 +109,10 @@
|
|||||||
"http"
|
"http"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"maxLength": 500,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"expect": {
|
"expect": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -404,6 +408,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
|
"maxLength": 30,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -411,6 +416,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
|
"maxLength": 30,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -504,6 +510,10 @@
|
|||||||
"cmd"
|
"cmd"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"maxLength": 500,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"expect": {
|
"expect": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -640,6 +650,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
|
"maxLength": 30,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -647,6 +658,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
|
"maxLength": 30,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -707,6 +719,10 @@
|
|||||||
"db"
|
"db"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"maxLength": 500,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"expect": {
|
"expect": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -869,6 +885,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
|
"maxLength": 30,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -876,6 +893,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
|
"maxLength": 30,
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ targets:
|
|||||||
|
|
||||||
- id: "baidu-home"
|
- id: "baidu-home"
|
||||||
name: "Baidu 首页可用"
|
name: "Baidu 首页可用"
|
||||||
|
description: "监控百度首页的可用性和响应时间"
|
||||||
type: http
|
type: http
|
||||||
group: "搜索引擎"
|
group: "搜索引擎"
|
||||||
http:
|
http:
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ function resolveTarget(
|
|||||||
|
|
||||||
result.intervalMs = intervalMs;
|
result.intervalMs = intervalMs;
|
||||||
result.timeoutMs = timeoutMs;
|
result.timeoutMs = timeoutMs;
|
||||||
|
result.description = target.description ?? null;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
|
|||||||
exec: t.cmd.exec,
|
exec: t.cmd.exec,
|
||||||
maxOutputBytes,
|
maxOutputBytes,
|
||||||
},
|
},
|
||||||
|
description: null,
|
||||||
expect: target.expect as CommandExpectConfig | undefined,
|
expect: target.expect as CommandExpectConfig | undefined,
|
||||||
group: target.group ?? "default",
|
group: target.group ?? "default",
|
||||||
id: t.id,
|
id: t.id,
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
|
|||||||
query: t.db.query,
|
query: t.db.query,
|
||||||
url: t.db.url,
|
url: t.db.url,
|
||||||
},
|
},
|
||||||
|
description: null,
|
||||||
expect: target.expect as DbExpectConfig | undefined,
|
expect: target.expect as DbExpectConfig | undefined,
|
||||||
group: target.group ?? "default",
|
group: target.group ?? "default",
|
||||||
id: t.id,
|
id: t.id,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
|||||||
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
|
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
description: null,
|
||||||
expect: target.expect as HttpExpectConfig | undefined,
|
expect: target.expect as HttpExpectConfig | undefined,
|
||||||
group: target.group ?? "default",
|
group: target.group ?? "default",
|
||||||
http: {
|
http: {
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
|
|||||||
|
|
||||||
export function createTargetSchema(checker: CheckerDefinition): TSchema {
|
export function createTargetSchema(checker: CheckerDefinition): TSchema {
|
||||||
const properties: Record<string, TSchema> = {
|
const properties: Record<string, TSchema> = {
|
||||||
|
description: Type.Optional(Type.String({ maxLength: 500 })),
|
||||||
expect: Type.Optional(checker.schemas.expect),
|
expect: Type.Optional(checker.schemas.expect),
|
||||||
group: Type.Optional(Type.String()),
|
group: Type.Optional(Type.String()),
|
||||||
id: Type.String({ minLength: 1 }),
|
id: Type.String({ maxLength: 30, minLength: 1 }),
|
||||||
interval: Type.Optional(durationSchema),
|
interval: Type.Optional(durationSchema),
|
||||||
name: Type.Optional(Type.String({ minLength: 1 })),
|
name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })),
|
||||||
timeout: Type.Optional(durationSchema),
|
timeout: Type.Optional(durationSchema),
|
||||||
type: Type.Literal(checker.type),
|
type: Type.Literal(checker.type),
|
||||||
};
|
};
|
||||||
@@ -68,10 +69,11 @@ function cloneSchema(schema: TSchema): Record<string, unknown> {
|
|||||||
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
|
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
|
||||||
return Type.Object(
|
return Type.Object(
|
||||||
{
|
{
|
||||||
|
description: Type.Optional(Type.String({ maxLength: 500 })),
|
||||||
group: Type.Optional(Type.String()),
|
group: Type.Optional(Type.String()),
|
||||||
id: Type.String({ minLength: 1 }),
|
id: Type.String({ maxLength: 30, minLength: 1 }),
|
||||||
interval: Type.Optional(durationSchema),
|
interval: Type.Optional(durationSchema),
|
||||||
name: Type.Optional(Type.String({ minLength: 1 })),
|
name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })),
|
||||||
timeout: Type.Optional(durationSchema),
|
timeout: Type.Optional(durationSchema),
|
||||||
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
|
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const CREATE_TARGETS_TABLE = `
|
|||||||
CREATE TABLE IF NOT EXISTS targets (
|
CREATE TABLE IF NOT EXISTS targets (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
target TEXT NOT NULL,
|
target TEXT NOT NULL,
|
||||||
config TEXT NOT NULL DEFAULT '{}',
|
config TEXT NOT NULL DEFAULT '{}',
|
||||||
@@ -316,10 +317,10 @@ export class ProbeStore {
|
|||||||
const configIds = new Set(targets.map((t) => t.id));
|
const configIds = new Set(targets.map((t) => t.id));
|
||||||
|
|
||||||
const insertStmt = this.db.prepare(
|
const insertStmt = this.db.prepare(
|
||||||
"INSERT INTO targets (id, name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO targets (id, name, description, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
);
|
);
|
||||||
const updateStmt = this.db.prepare(
|
const updateStmt = this.db.prepare(
|
||||||
"UPDATE targets SET name = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
|
"UPDATE targets SET name = ?, description = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
|
||||||
);
|
);
|
||||||
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
|
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
|
||||||
|
|
||||||
@@ -332,9 +333,9 @@ export class ProbeStore {
|
|||||||
const expect = t.expect ? JSON.stringify(t.expect) : null;
|
const expect = t.expect ? JSON.stringify(t.expect) : null;
|
||||||
|
|
||||||
if (existingIds.has(t.id)) {
|
if (existingIds.has(t.id)) {
|
||||||
updateStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);
|
updateStmt.run(t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);
|
||||||
} else {
|
} else {
|
||||||
insertStmt.run(t.id, t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
|
insertStmt.run(t.id, t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface ProbeConfig {
|
|||||||
|
|
||||||
export interface RawTargetConfig {
|
export interface RawTargetConfig {
|
||||||
[configKey: string]: unknown;
|
[configKey: string]: unknown;
|
||||||
|
description?: string;
|
||||||
expect?: unknown;
|
expect?: unknown;
|
||||||
group?: string;
|
group?: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,6 +53,7 @@ export interface RawTargetConfig {
|
|||||||
|
|
||||||
export interface ResolvedTargetBase {
|
export interface ResolvedTargetBase {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
description: null | string;
|
||||||
expect?: unknown;
|
expect?: unknown;
|
||||||
group: string;
|
group: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -79,6 +81,7 @@ export interface StoredCheckResult {
|
|||||||
|
|
||||||
export interface StoredTarget {
|
export interface StoredTarget {
|
||||||
config: string;
|
config: string;
|
||||||
|
description: null | string;
|
||||||
expect: null | string;
|
expect: null | string;
|
||||||
grp: string;
|
grp: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
currentStreak,
|
currentStreak,
|
||||||
|
description: target.description,
|
||||||
group: target.grp,
|
group: target.grp,
|
||||||
id: target.id,
|
id: target.id,
|
||||||
interval: formatDuration(target.interval_ms),
|
interval: formatDuration(target.interval_ms),
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export interface TargetStats {
|
|||||||
|
|
||||||
export interface TargetStatus {
|
export interface TargetStatus {
|
||||||
currentStreak: CurrentStreak | null;
|
currentStreak: CurrentStreak | null;
|
||||||
|
description: null | string;
|
||||||
group: string;
|
group: string;
|
||||||
id: string;
|
id: string;
|
||||||
interval: string;
|
interval: string;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
|||||||
label: "最新检查时间",
|
label: "最新检查时间",
|
||||||
},
|
},
|
||||||
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
|
||||||
|
{ content: target.description ?? "", label: "描述", span: 2 },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { StatusBar } from "../components/StatusBar";
|
|||||||
import { StatusDot } from "../components/StatusDot";
|
import { StatusDot } from "../components/StatusDot";
|
||||||
import { getAvailabilityProgressColor } from "./color-threshold";
|
import { getAvailabilityProgressColor } from "./color-threshold";
|
||||||
import { statusFilter } from "./target-table-filters";
|
import { statusFilter } from "./target-table-filters";
|
||||||
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters";
|
import { availabilitySorter, latencySorter } from "./target-table-sorters";
|
||||||
|
|
||||||
export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> {
|
export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> {
|
||||||
return [
|
return [
|
||||||
@@ -24,8 +24,6 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
|||||||
{
|
{
|
||||||
colKey: "name",
|
colKey: "name",
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
sorter: nameSorter,
|
|
||||||
sortType: "all",
|
|
||||||
title: "名称",
|
title: "名称",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,13 +86,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
|
|||||||
const ms = row.latestCheck?.durationMs;
|
const ms = row.latestCheck?.durationMs;
|
||||||
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
|
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
|
||||||
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
|
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
|
||||||
const latencyText = ms > 9999 ? "9999+ms" : `${Math.round(ms)}ms`;
|
const latencyText = ms > 9999 ? "9999+" : `${Math.round(ms)}`;
|
||||||
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
|
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
|
||||||
},
|
},
|
||||||
colKey: "latestCheck.durationMs",
|
colKey: "latestCheck.durationMs",
|
||||||
sorter: latencySorter,
|
sorter: latencySorter,
|
||||||
sortType: "all",
|
sortType: "all",
|
||||||
title: "延迟",
|
title: "延迟(ms)",
|
||||||
width: 75,
|
width: 75,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ export function latencySorter(a: TargetStatus, b: TargetStatus): number {
|
|||||||
return (a.latestCheck?.durationMs ?? Infinity) - (b.latestCheck?.durationMs ?? Infinity);
|
return (a.latestCheck?.durationMs ?? Infinity) - (b.latestCheck?.durationMs ?? Infinity);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nameSorter(a: TargetStatus, b: TargetStatus): number {
|
|
||||||
return a.name.localeCompare(b.name, "zh-CN");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function statusSorter(a: TargetStatus, b: TargetStatus): number {
|
export function statusSorter(a: TargetStatus, b: TargetStatus): number {
|
||||||
return getStatusRank(a) - getStatusRank(b);
|
return getStatusRank(a) - getStatusRank(b);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ describe("API 路由", () => {
|
|||||||
store = new ProbeStore(join(tempDir, "test.db"));
|
store = new ProbeStore(join(tempDir, "test.db"));
|
||||||
store.syncTargets([
|
store.syncTargets([
|
||||||
{
|
{
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
http: {
|
http: {
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -64,6 +65,7 @@ describe("API 路由", () => {
|
|||||||
exec: "echo",
|
exec: "echo",
|
||||||
maxOutputBytes: 104857600,
|
maxOutputBytes: 104857600,
|
||||||
},
|
},
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "test-b",
|
id: "test-b",
|
||||||
intervalMs: 60000,
|
intervalMs: 60000,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstra
|
|||||||
type ShutdownSignal = "SIGINT" | "SIGTERM";
|
type ShutdownSignal = "SIGINT" | "SIGTERM";
|
||||||
|
|
||||||
const target: ResolvedTargetBase = {
|
const target: ResolvedTargetBase = {
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "test",
|
id: "test",
|
||||||
intervalMs: 30000,
|
intervalMs: 30000,
|
||||||
|
|||||||
@@ -1534,4 +1534,150 @@ targets:
|
|||||||
"无效的时长格式",
|
"无效的时长格式",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("解析 description 字段", async () => {
|
||||||
|
const configPath = join(tempDir, "description.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "api-health"
|
||||||
|
description: "检查生产 API 健康状态"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets[0]!.description).toBe("检查生产 API 健康状态");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("description 使用变量替换", async () => {
|
||||||
|
const configPath = join(tempDir, "description-var.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`variables:
|
||||||
|
env: "生产"
|
||||||
|
targets:
|
||||||
|
- id: "api-health"
|
||||||
|
description: "\${env} 环境健康检查"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets[0]!.description).toBe("生产 环境健康检查");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("description 缺省为 null", async () => {
|
||||||
|
const configPath = join(tempDir, "no-description.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "api-health"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets[0]!.description).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("description 为空字符串通过", async () => {
|
||||||
|
const configPath = join(tempDir, "empty-description.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "api-health"
|
||||||
|
description: ""
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
expect(config.targets[0]!.description).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("description 非字符串抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "bad-description-type.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "api-health"
|
||||||
|
description: 123
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||||
|
await expect(loadConfig(configPath)).rejects.toThrow("description");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("description 超过 500 字符抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "long-description.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "api-health"
|
||||||
|
description: "${"a".repeat(501)}"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||||
|
await expect(loadConfig(configPath)).rejects.toThrow("description");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("变量替换后 description 超长抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "var-long-description.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`variables:
|
||||||
|
prefix: "${"x".repeat(490)}"
|
||||||
|
targets:
|
||||||
|
- id: "api-health"
|
||||||
|
description: "\${prefix}${"a".repeat(15)}"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||||
|
await expect(loadConfig(configPath)).rejects.toThrow("description");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("id 超过 30 字符抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "long-id.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "${"a".repeat(31)}"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||||
|
await expect(loadConfig(configPath)).rejects.toThrow("id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("name 超过 30 字符抛出错误", async () => {
|
||||||
|
const configPath = join(tempDir, "long-name.yaml");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
`targets:
|
||||||
|
- id: "test"
|
||||||
|
name: "${"a".repeat(31)}"
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
url: "http://example.com"
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||||
|
await expect(loadConfig(configPath)).rejects.toThrow("name");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
|
|||||||
exec: "bun",
|
exec: "bun",
|
||||||
maxOutputBytes: 1024 * 1024,
|
maxOutputBytes: 1024 * 1024,
|
||||||
},
|
},
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: name,
|
id: name,
|
||||||
intervalMs: 60000,
|
intervalMs: 60000,
|
||||||
@@ -259,6 +260,7 @@ describe("ProbeEngine", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const httpTarget: ResolvedHttpTarget = {
|
const httpTarget: ResolvedHttpTarget = {
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
http: {
|
http: {
|
||||||
headers: {},
|
headers: {},
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function makeTarget(
|
|||||||
maxOutputBytes: 1024 * 1024,
|
maxOutputBytes: 1024 * 1024,
|
||||||
...cmd,
|
...cmd,
|
||||||
},
|
},
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "test-cmd",
|
id: "test-cmd",
|
||||||
intervalMs: 60000,
|
intervalMs: 60000,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<Res
|
|||||||
url: "sqlite://:memory:",
|
url: "sqlite://:memory:",
|
||||||
...db,
|
...db,
|
||||||
},
|
},
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "test-db",
|
id: "test-db",
|
||||||
intervalMs: 60000,
|
intervalMs: 60000,
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ describe("HttpChecker", () => {
|
|||||||
url?: string;
|
url?: string;
|
||||||
}): ResolvedHttpTarget {
|
}): ResolvedHttpTarget {
|
||||||
return {
|
return {
|
||||||
|
description: null,
|
||||||
expect: overrides.expect,
|
expect: overrides.expect,
|
||||||
group: "default",
|
group: "default",
|
||||||
http: {
|
http: {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function targetId(store: ProbeStore, name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const httpTarget: ResolvedHttpTarget = {
|
const httpTarget: ResolvedHttpTarget = {
|
||||||
|
description: null,
|
||||||
expect: { maxDurationMs: 3000, status: [200] },
|
expect: { maxDurationMs: 3000, status: [200] },
|
||||||
group: "default",
|
group: "default",
|
||||||
http: {
|
http: {
|
||||||
@@ -54,6 +55,7 @@ const commandTarget: ResolvedCommandTarget = {
|
|||||||
exec: "ping",
|
exec: "ping",
|
||||||
maxOutputBytes: 104857600,
|
maxOutputBytes: 104857600,
|
||||||
},
|
},
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "test-cmd",
|
id: "test-cmd",
|
||||||
intervalMs: 60000,
|
intervalMs: 60000,
|
||||||
@@ -364,6 +366,7 @@ describe("ProbeStore", () => {
|
|||||||
test("删除 target 级联删除 check_results", () => {
|
test("删除 target 级联删除 check_results", () => {
|
||||||
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
|
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
|
||||||
const cascadeTarget: ResolvedHttpTarget = {
|
const cascadeTarget: ResolvedHttpTarget = {
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
http: {
|
http: {
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -427,6 +430,7 @@ describe("ProbeStore", () => {
|
|||||||
const freshStore = new ProbeStore(join(tempDir, "fresh-map.db"));
|
const freshStore = new ProbeStore(join(tempDir, "fresh-map.db"));
|
||||||
freshStore.syncTargets([
|
freshStore.syncTargets([
|
||||||
{
|
{
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
http: {
|
http: {
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -474,6 +478,7 @@ describe("ProbeStore", () => {
|
|||||||
const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db"));
|
const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db"));
|
||||||
freshStore.syncTargets([
|
freshStore.syncTargets([
|
||||||
{
|
{
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
http: {
|
http: {
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -662,4 +667,35 @@ describe("ProbeStore", () => {
|
|||||||
|
|
||||||
pruneStore.close();
|
pruneStore.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("syncTargets 持久化 description", () => {
|
||||||
|
const descStore = new ProbeStore(join(tempDir, "desc.db"));
|
||||||
|
const targetWithDesc: ResolvedHttpTarget = {
|
||||||
|
...httpTarget,
|
||||||
|
description: "检查 API 健康状态",
|
||||||
|
id: "desc-test",
|
||||||
|
name: "desc-test",
|
||||||
|
};
|
||||||
|
descStore.syncTargets([targetWithDesc]);
|
||||||
|
const t = descStore.getTargets()[0]!;
|
||||||
|
expect(t.description).toBe("检查 API 健康状态");
|
||||||
|
descStore.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("未配置 description 时持久化为 null", () => {
|
||||||
|
const noDescStore = new ProbeStore(join(tempDir, "no-desc.db"));
|
||||||
|
noDescStore.syncTargets([{ ...httpTarget, description: null, id: "no-desc", name: "no-desc" }]);
|
||||||
|
const t = noDescStore.getTargets()[0]!;
|
||||||
|
expect(t.description).toBeNull();
|
||||||
|
noDescStore.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("同步更新 description", () => {
|
||||||
|
const updateDescStore = new ProbeStore(join(tempDir, "update-desc.db"));
|
||||||
|
updateDescStore.syncTargets([{ ...httpTarget, description: "旧描述", id: "update-desc", name: "update-desc" }]);
|
||||||
|
updateDescStore.syncTargets([{ ...httpTarget, description: "新描述", id: "update-desc", name: "update-desc" }]);
|
||||||
|
const t = updateDescStore.getTargets()[0]!;
|
||||||
|
expect(t.description).toBe("新描述");
|
||||||
|
updateDescStore.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { OverviewTab } from "../../../src/web/components/OverviewTab";
|
|||||||
describe("OverviewTab", () => {
|
describe("OverviewTab", () => {
|
||||||
const target: TargetStatus = {
|
const target: TargetStatus = {
|
||||||
currentStreak: null,
|
currentStreak: null,
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "1",
|
id: "1",
|
||||||
interval: "30s",
|
interval: "30s",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe("TargetBoard", () => {
|
|||||||
const targets: TargetStatus[] = [
|
const targets: TargetStatus[] = [
|
||||||
{
|
{
|
||||||
currentStreak: null,
|
currentStreak: null,
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "1",
|
id: "1",
|
||||||
interval: "30s",
|
interval: "30s",
|
||||||
@@ -31,6 +32,7 @@ describe("TargetBoard", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
currentStreak: null,
|
currentStreak: null,
|
||||||
|
description: null,
|
||||||
group: "production",
|
group: "production",
|
||||||
id: "2",
|
id: "2",
|
||||||
interval: "30s",
|
interval: "30s",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TargetDetailDrawer } from "../../../src/web/components/TargetDetailDraw
|
|||||||
describe("TargetDetailDrawer", () => {
|
describe("TargetDetailDrawer", () => {
|
||||||
const target: TargetStatus = {
|
const target: TargetStatus = {
|
||||||
currentStreak: null,
|
currentStreak: null,
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "1",
|
id: "1",
|
||||||
interval: "30s",
|
interval: "30s",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe("TargetGroup", () => {
|
|||||||
const targets: TargetStatus[] = [
|
const targets: TargetStatus[] = [
|
||||||
{
|
{
|
||||||
currentStreak: null,
|
currentStreak: null,
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "1",
|
id: "1",
|
||||||
interval: "30s",
|
interval: "30s",
|
||||||
@@ -33,6 +34,7 @@ describe("TargetGroup", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
currentStreak: null,
|
currentStreak: null,
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "2",
|
id: "2",
|
||||||
interval: "30s",
|
interval: "30s",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function getColumn(columns: Array<PrimaryTableCol<TargetStatus>>, colKey: string
|
|||||||
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||||
return {
|
return {
|
||||||
currentStreak: null,
|
currentStreak: null,
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "1",
|
id: "1",
|
||||||
interval: "5s",
|
interval: "5s",
|
||||||
@@ -119,7 +120,40 @@ describe("createTargetTableColumns", () => {
|
|||||||
rowIndex: 0,
|
rowIndex: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(element.props.children).toBe("9999+ms");
|
expect(element.props.children).toBe("9999+");
|
||||||
expect(element.props.className).toContain("latency-value");
|
expect(element.props.className).toContain("latency-value");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("延迟列标题为 延迟(ms)", () => {
|
||||||
|
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
|
||||||
|
expect(latencyColumn.title).toBe("延迟(ms)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("延迟列正常值不包含 ms 单位", () => {
|
||||||
|
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
|
||||||
|
const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
|
||||||
|
props: { children: string; className: string };
|
||||||
|
};
|
||||||
|
const element = renderCell({
|
||||||
|
col: latencyColumn,
|
||||||
|
colIndex: 6,
|
||||||
|
row: makeTarget({
|
||||||
|
latestCheck: {
|
||||||
|
durationMs: 123,
|
||||||
|
failure: null,
|
||||||
|
matched: true,
|
||||||
|
statusDetail: "200",
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
rowIndex: 0,
|
||||||
|
});
|
||||||
|
expect(element.props.children).toBe("123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("名称列无排序配置", () => {
|
||||||
|
const nameColumn = getColumn(createTargetTableColumns(["http"]), "name");
|
||||||
|
expect(nameColumn.sorter).toBeUndefined();
|
||||||
|
expect(nameColumn.sortType).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,16 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|||||||
|
|
||||||
import type { TargetStatus } from "../../../src/shared/api";
|
import type { TargetStatus } from "../../../src/shared/api";
|
||||||
|
|
||||||
import {
|
import { availabilitySorter, latencySorter, statusSorter } from "../../../src/web/constants/target-table-sorters";
|
||||||
availabilitySorter,
|
|
||||||
latencySorter,
|
|
||||||
nameSorter,
|
|
||||||
statusSorter,
|
|
||||||
} from "../../../src/web/constants/target-table-sorters";
|
|
||||||
|
|
||||||
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
|
||||||
return {
|
return {
|
||||||
currentStreak: null,
|
currentStreak: null,
|
||||||
|
description: null,
|
||||||
group: "default",
|
group: "default",
|
||||||
id: "1",
|
id: "1",
|
||||||
interval: "5s",
|
interval: "5s",
|
||||||
@@ -97,18 +93,3 @@ describe("latencySorter", () => {
|
|||||||
expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0);
|
expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("nameSorter", () => {
|
|
||||||
test("按名称字母排序", () => {
|
|
||||||
const a = makeTarget({ name: "Alpha" });
|
|
||||||
const b = makeTarget({ name: "Beta" });
|
|
||||||
expect(nameSorter(a, b)).toBeLessThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("中文名称排序", () => {
|
|
||||||
const a = makeTarget({ name: "百度" });
|
|
||||||
const b = makeTarget({ name: "谷歌" });
|
|
||||||
const result = nameSorter(a, b);
|
|
||||||
expect(typeof result).toBe("number");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user