1
0

refactor: 统一 target name/description 可空语义,前端展示 fallback 到 id

- schema: name/description 允许省略或显式 null,TypeBox Union([Null, String])
- 类型: RawTargetConfig/ResolvedTargetBase/子类型/StoredTarget/TargetStatus name 改为 string | null
- checker resolve: name: t.name ?? null,不再 fallback 到 id
- 语义校验: 拒绝空字符串和纯空白 name
- SQLite: targets.name 列改为可空 TEXT
- 前端: 新增 getTargetDisplayName(target) 展示 name ?? id
- 测试: 覆盖 name/description null 全场景,查找改为按 id
- 文档: 更新 README/DEVELOPMENT 和 6 个 openspec specs
This commit is contained in:
2026-05-17 20:12:39 +08:00
parent f7193e98ff
commit 31fd3a2a43
29 changed files with 382 additions and 119 deletions

View File

@@ -463,7 +463,7 @@ TcpChecker implements Checker
**Schema** **Schema**
- `targets`idTEXT PRIMARY KEY配置 target id、name展示名称、description描述、type、target展示摘要、configJSON、interval_ms、timeout_ms、expectJSON、grp - `targets`idTEXT PRIMARY KEY配置 target id、nameTEXT可 NULL展示名称、descriptionTEXT可 NULL描述、type、target展示摘要、configJSON、interval_ms、timeout_ms、expectJSON、grp
- `check_results`target_idTEXT FK CASCADE引用配置 target id、timestamp、matched0/1、duration_ms、status_detail、failureJSON - `check_results`target_idTEXT FK CASCADE引用配置 target id、timestamp、matched0/1、duration_ms、status_detail、failureJSON
- 复合索引:`(target_id, timestamp)` - 复合索引:`(target_id, timestamp)`

View File

@@ -176,15 +176,15 @@ targets:
每个 target 的通用字段: 每个 target 的通用字段:
| 字段 | 说明 | 必填 | | 字段 | 说明 | 必填 |
| ------------- | ------------------------------------------------------------------------ | -------------------- | | ------------- | ------------------------------------------------------------------------------------ | -------------------- |
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | | `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
| `name` | 展示名称,最长 30 字符,支持变量替换;省略时使用 `id` | 否 | | `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null前端展示时 null 回退到 `id` | 否 |
| `description` | 目标描述,最长 500 字符,支持变量替换,允许空字符串 | 否 | | `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null允许空字符串 | 否 |
| `type` | 目标类型:`http``cmd``db` | 是 | | `type` | 目标类型:`http``cmd``db` | 是 |
| `group` | 分组名称 | 否,默认 `"default"` | | `group` | 分组名称 | 否,默认 `"default"` |
| `interval` | 覆盖全局拨测间隔 | 否 | | `interval` | 覆盖全局拨测间隔 | 否 |
| `timeout` | 覆盖全局超时时间 | 否 | | `timeout` | 覆盖全局超时时间 | 否 |
**HTTP 类型** (`type: http`) **HTTP 类型** (`type: http`)

View File

@@ -17,7 +17,11 @@
#### Scenario: targets 字段 #### Scenario: targets 字段
- **WHEN** Dashboard 响应包含 targets - **WHEN** Dashboard 响应包含 targets
- **THEN** targets 数组中每个元素 SHALL 包含目标基本信息id、name、description、group、type、target、interval、latestCheck、stats、currentStreak 和 recentSamples 字段,其中 description 为 null 或字符串 - **THEN** targets 数组中每个元素 SHALL 包含目标基本信息id、name、description、group、type、target、interval、latestCheck、stats、currentStreak 和 recentSamples 字段,其中 name 和 description 为 null 或字符串
#### Scenario: target name 字段为 null
- **WHEN** 某个 target 未配置 `name` 或显式配置 `name: null`
- **THEN** Dashboard targets 响应中对应元素 SHALL 返回 `name: null`
#### Scenario: target description 字段 #### Scenario: target description 字段
- **WHEN** 某个 target 配置了 `description` - **WHEN** 某个 target 配置了 `description`
@@ -109,7 +113,7 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
#### Scenario: TargetStatus 类型 #### Scenario: TargetStatus 类型
- **WHEN** 前后端共享 `TargetStatus` 类型 - **WHEN** 前后端共享 `TargetStatus` 类型
- **THEN** 该类型 SHALL 包含目标基本信息字段id、name、description、group、type、target、interval、statstotalChecks、upChecks、downChecks、availability、currentStreak 和 recentSamples 字段,其中 description 类型为 null 或字符串 - **THEN** 该类型 SHALL 包含目标基本信息字段id、name、description、group、type、target、interval、statstotalChecks、upChecks、downChecks、availability、currentStreak 和 recentSamples 字段,其中 name 和 description 类型为 null 或字符串
#### Scenario: TargetMetricsResponse 类型 #### Scenario: TargetMetricsResponse 类型
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型 - **WHEN** 前后端共享 `TargetMetricsResponse` 类型

View File

@@ -5,7 +5,7 @@
## Requirements ## Requirements
### Requirement: YAML 配置文件格式 ### Requirement: YAML 配置文件格式
系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称(缺省 fallback 到 id。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。 系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、可选的 variables 段、checker 默认值和 typed target 列表(含可选 group 字段。target MUST 使用 `id` 字段作为唯一标识符MUST 使用 `type` 字段声明 checker 类型SHALL 支持可选的 `name` 字段作为展示名称元信息SHALL 支持可选的 `description` 字段作为目标说明。`name``description` 均 SHALL 允许省略或显式配置为 `null`;省略或显式 null 时解析结果 SHALL 保留为 null。HTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。 `defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。
@@ -15,11 +15,11 @@
#### Scenario: 最简 HTTP 配置文件解析 #### Scenario: 最简 HTTP 配置文件解析
- **WHEN** 系统读取只包含一个 `type: http` target`id``http.url`)的 YAML 配置文件(省略 server、runtime、variables、defaults 和 expect - **WHEN** 系统读取只包含一个 `type: http` target`id``http.url`)的 YAML 配置文件(省略 server、runtime、variables、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, http.ignoreSSL=false, http.maxRedirects=0, group="default", name fallback 到 id - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default"),并保留 name=null、description=null
#### Scenario: 最简 cmd 配置文件解析 #### Scenario: 最简 cmd 配置文件解析
- **WHEN** 系统读取只包含一个 `type: cmd` target`id``cmd.exec`)的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: cmd` target`id``cmd.exec`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB, name fallback 到 id - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB),并保留 name=null、description=null
#### Scenario: per-target 配置覆盖全局默认值 #### Scenario: per-target 配置覆盖全局默认值
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段 - **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
@@ -35,7 +35,7 @@
#### Scenario: 最简 db 配置文件解析 #### Scenario: 最简 db 配置文件解析
- **WHEN** 系统读取只包含一个 `type: db` target`id``db.url`)的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: db` target`id``db.url`)的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", name fallback 到 id - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default"),并保留 name=null、description=null
#### Scenario: defaults.http.method 触发校验错误 #### Scenario: defaults.http.method 触发校验错误
- **WHEN** 配置文件中出现 `defaults.http.method` 字段 - **WHEN** 配置文件中出现 `defaults.http.method` 字段
@@ -330,18 +330,30 @@
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径 - **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径
### Requirement: target 通用元信息字段约束 ### Requirement: target 通用元信息字段约束
系统 SHALL 在 YAML target 通用字段中支持 `description` 字段,并对 `id``name``description` 执行契约校验。`id` MUST 为 1 到 30 个字符,显式 `name` MUST 为 1 到 30 个字符`description` MUST 不超过 500 个字符且 MAY 为空字符串。 系统 SHALL 在 YAML target 通用字段中支持 `description` 字段,并对 `id``name``description` 执行契约校验。`id` MUST 为 1 到 30 个字符`name` MUST 为 null 或 1 到 30 个字符的字符串,且语义校验 SHALL 拒绝仅包含空白字符的 name。`description` MUST 为 null 或不超过 500 个字符的字符串,且 MAY 为空字符串。
#### Scenario: description 字段解析 #### Scenario: description 字段解析
- **WHEN** 系统读取包含 `description: "检查生产 API 健康状态"` 的 target - **WHEN** 系统读取包含 `description: "检查生产 API 健康状态"` 的 target
- **THEN** 系统 SHALL 将该字段解析为 target 的目标说明 - **THEN** 系统 SHALL 将该字段解析为 target 的目标说明
#### Scenario: name 为 null 通过校验
- **WHEN** 系统读取包含 `name: null` 或省略 `name` 的 target
- **THEN** 系统 SHALL 接受该配置
#### Scenario: name 仅包含空白字符报错
- **WHEN** 系统读取包含 `name: " "` 的 target
- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空白
#### Scenario: description 为空字符串 #### Scenario: description 为空字符串
- **WHEN** 系统读取包含 `description: ""` 的 target - **WHEN** 系统读取包含 `description: ""` 的 target
- **THEN** 系统 SHALL 接受该配置,且不触发长度错误 - **THEN** 系统 SHALL 接受该配置,且不触发长度错误
#### Scenario: description 为 null 通过校验
- **WHEN** 系统读取包含 `description: null` 或省略 `description` 的 target
- **THEN** 系统 SHALL 接受该配置
#### Scenario: description 类型非法 #### Scenario: description 类型非法
- **WHEN** YAML 中某个 target 的 `description` 字段不是字符串 - **WHEN** YAML 中某个 target 的 `description` 字段不是字符串也不是 null
- **THEN** 系统 SHALL 以错误退出,提示 description 字段类型错误 - **THEN** 系统 SHALL 以错误退出,提示 description 字段类型错误
#### Scenario: description 超过最大长度 #### Scenario: description 超过最大长度
@@ -361,12 +373,12 @@
- **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误 - **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误
### Requirement: 配置 schema 导出包含 target 元信息约束 ### Requirement: 配置 schema 导出包含 target 元信息约束
系统 SHALL 在导出的 `probe-config.schema.json` 中包含 target `id``name``description` 的长度约束,用于编辑器提示和外部校验。 系统 SHALL 在导出的 `probe-config.schema.json` 中包含 target `id``name``description` 的长度约束和可空类型,用于编辑器提示和外部校验。
#### Scenario: schema 导出 description #### Scenario: schema 导出 description
- **WHEN** 系统导出 `probe-config.schema.json` - **WHEN** 系统导出 `probe-config.schema.json`
- **THEN** target schema SHALL 包含可选的 `description` 字段,类型为 string最大长度为 500 - **THEN** target schema SHALL 包含可选的 `description` 字段,类型为 string 或 null字符串最大长度为 500
#### Scenario: schema 导出 id 和 name 长度 #### Scenario: schema 导出 id 和 name
- **WHEN** 系统导出 `probe-config.schema.json` - **WHEN** 系统导出 `probe-config.schema.json`
- **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30并声明 `name` 的 minLength 为 1、maxLength 为 30 - **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30并声明 `name` 为可选字段,类型为 string 或 null字符串的 minLength 为 1、maxLength 为 30

View File

@@ -5,12 +5,16 @@
## Requirements ## Requirements
### Requirement: SQLite 数据库初始化 ### Requirement: SQLite 数据库初始化
系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果targets 表 MUST 包含 `grp` 列存储分组信息。 系统 SHALL 使用 Bun 内置 `bun:sqlite` 模块在配置的数据目录下创建 SQLite 数据库文件,并以 WAL 模式运行。数据库 schema MUST 支持 typed checker target 和结构化检查结果targets 表 MUST 包含 `grp` 列存储分组信息,且 targets 表的 `name``description` 列 MUST 允许 NULL
#### Scenario: 首次启动创建数据库 #### Scenario: 首次启动创建数据库
- **WHEN** 指定的数据目录下不存在数据库文件 - **WHEN** 指定的数据目录下不存在数据库文件
- **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表check_results 表包含 idINTEGER PRIMARY KEY AUTOINCREMENT、target_idINTEGER NOT NULL、timestampTEXT NOT NULL、matchedINTEGER NOT NULL、duration_msREAL、status_detailTEXT、failureTEXT不包含 success 列 - **THEN** 系统 SHALL 创建数据库文件并初始化 targets 表和 check_results 表check_results 表包含 idINTEGER PRIMARY KEY AUTOINCREMENT、target_idINTEGER NOT NULL、timestampTEXT NOT NULL、matchedINTEGER NOT NULL、duration_msREAL、status_detailTEXT、failureTEXT不包含 success 列
#### Scenario: targets name 列允许 NULL
- **WHEN** 系统首次创建 targets 表
- **THEN** targets.name 列 SHALL 允许存储 NULL
#### Scenario: 数据目录不存在 #### Scenario: 数据目录不存在
- **WHEN** 配置的数据目录路径不存在 - **WHEN** 配置的数据目录路径不存在
- **THEN** 系统 SHALL 自动创建该目录 - **THEN** 系统 SHALL 自动创建该目录
@@ -26,20 +30,32 @@
- **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、description、type、target、config、interval_ms、timeout_ms、expect 和 grp - **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect 和 grp,其中 name 和 description 均可为 NULL
#### Scenario: 配置变更后重新同步 #### Scenario: 配置变更后重新同步
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启 - **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 description 和 grp 字段) - **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 name、description 和 grp 字段)
#### Scenario: 未配置 name
- **WHEN** YAML target 未配置 `name`
- **THEN** targets 表 SHALL 将该目标的 name 存储为 NULL
#### Scenario: name 显式 null
- **WHEN** YAML target 配置 `name: null`
- **THEN** targets 表 SHALL 将该目标的 name 存储为 NULL
#### Scenario: 未配置 description #### Scenario: 未配置 description
- **WHEN** YAML target 未配置 `description` - **WHEN** YAML target 未配置 `description`
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL - **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
#### Scenario: description 显式 null
- **WHEN** YAML target 配置 `description: null`
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
### Requirement: check_results 表追加写入 ### Requirement: check_results 表追加写入
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。 系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。

View File

@@ -13,7 +13,11 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
#### Scenario: Drawer 标题栏 #### Scenario: Drawer 标题栏
- **WHEN** Drawer 渲染 - **WHEN** Drawer 渲染
- **THEN** 标题栏 SHALL 使用 TDesign Space 组件align="center")布局,包含 StatusDot、目标名称TDesign Typography.Text strong和类型标签TDesign Tag直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局 - **THEN** 标题栏 SHALL 使用 TDesign Space 组件align="center")布局,包含 StatusDot、目标展示名称(取值为 `target.name ?? target.id`,使用 TDesign Typography.Text strong和类型标签TDesign Tag直接显示 target.type 原始文本),以及内建关闭按钮。不使用内联 style 的 flex 布局
#### Scenario: Drawer 标题栏 name 为 null
- **WHEN** Drawer 渲染某个 `target.name` 为 null 的目标
- **THEN** 标题栏 SHALL 显示该目标的 `target.id` 作为目标展示名称
#### Scenario: 关闭 Drawer #### Scenario: 关闭 Drawer
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层 - **WHEN** 用户点击关闭按钮、ESC 键或遮罩层

View File

@@ -36,23 +36,39 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复 - **THEN** 系统 SHALL 以配置错误退出,提示 id 重复
### Requirement: target name 字段 ### Requirement: target name 字段
每个 target SHALL 支持可选的 `name` 字段作为展示名称。`name` 缺省时 SHALL fallback 到 `id` 的值作为展示名称。显式配置的 `name` 长度 MUST 为 1 到 30 个字符。`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一。 每个 target SHALL 支持可选的 `name` 字段作为展示名称元信息`name` 缺省或显式配置为 `null` 时 SHALL 在配置解析、运行时模型、存储和 API 中保留为 null。前端展示目标名称时 SHALL 使用 `name ?? id`,但该 fallback MUST NOT 改变 target 本身的 name 值。显式配置的 `name` MUST 为长度 1 到 30 个字符的字符串,且去除首尾空白后 MUST 不为空`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一MUST NOT 参与 target 唯一性判定
#### Scenario: 配置 name #### Scenario: 配置 name
- **WHEN** target 配置 `id: "api-health"``name: "API 健康检查"` - **WHEN** target 配置 `id: "api-health"``name: "API 健康检查"`
- **THEN** 系统 SHALL 使用 "API 健康检查" 作为展示名称 - **THEN** 系统 SHALL 在解析后保留 name 为 "API 健康检查"
#### Scenario: name 使用变量 #### Scenario: name 使用变量
- **WHEN** target 配置 `name: "${env} API 健康检查"` 且 variables 中 `env: "生产"` - **WHEN** target 配置 `name: "${env} API 健康检查"` 且 variables 中 `env: "生产"`
- **THEN** 系统 SHALL 将展示名称解析为 "生产 API 健康检查" - **THEN** 系统 SHALL 将 name 解析为 "生产 API 健康检查"
#### Scenario: name 缺省 fallback 到 id #### Scenario: name 缺省保留为 null
- **WHEN** target 配置 `id: "api-health"` 但未配置 `name` - **WHEN** target 配置 `id: "api-health"` 但未配置 `name`
- **THEN** 系统 SHALL 使用 "api-health" 作为展示名称 - **THEN** 系统 SHALL 在解析、存储和 API 响应中保留 name 为 null
#### Scenario: name 显式 null
- **WHEN** target 配置 `id: "api-health"``name: null`
- **THEN** 系统 SHALL 接受该配置,并在解析、存储和 API 响应中保留 name 为 null
#### Scenario: name 空 YAML 值
- **WHEN** target 配置 `id: "api-health"``name:` 后不提供值
- **THEN** 系统 SHALL 将该 name 按 null 处理,并接受该配置
#### Scenario: name 为 null 时展示 fallback
- **WHEN** 前端展示 name 为 null 的 target
- **THEN** 前端 SHALL 显示该 target 的 id 作为目标名称文案
#### Scenario: name 为空字符串报错 #### Scenario: name 为空字符串报错
- **WHEN** target 配置 `name: ""` - **WHEN** target 配置 `name: ""`
- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法 - **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空
#### Scenario: name 仅包含空白字符报错
- **WHEN** target 配置 `name: " "`
- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空
#### Scenario: name 超过最大长度报错 #### Scenario: name 超过最大长度报错
- **WHEN** target 配置超过 30 个字符的 `name` - **WHEN** target 配置超过 30 个字符的 `name`
@@ -63,7 +79,7 @@
- **THEN** 系统 SHALL 接受该配置不报错name 不要求全局唯一) - **THEN** 系统 SHALL 接受该配置不报错name 不要求全局唯一)
### Requirement: target description 字段 ### Requirement: target description 字段
每个 target SHALL 支持可选的 `description` 字段作为目标说明。`description` SHALL 支持变量替换。`description` 长度 MUST 不超过 500 个字符,且允许为空字符串。`description` MUST NOT 参与 target 唯一性判定。 每个 target SHALL 支持可选的 `description` 字段作为目标说明。`description` 缺省或显式配置为 `null` 时 SHALL 在配置解析、运行时模型、存储和 API 中保留为 null。`description` SHALL 支持变量替换。`description` 字符串长度 MUST 不超过 500 个字符,且允许为空字符串。`description` MUST NOT 参与 target 唯一性判定。
#### Scenario: 配置 description #### Scenario: 配置 description
- **WHEN** target 配置 `description: "检查生产 API 健康状态"` - **WHEN** target 配置 `description: "检查生产 API 健康状态"`
@@ -77,6 +93,14 @@
- **WHEN** target 未配置 `description` - **WHEN** target 未配置 `description`
- **THEN** 系统 SHALL 接受该配置,且目标说明为 null - **THEN** 系统 SHALL 接受该配置,且目标说明为 null
#### Scenario: description 显式 null
- **WHEN** target 配置 `description: null`
- **THEN** 系统 SHALL 接受该配置,且目标说明为 null
#### Scenario: description 空 YAML 值
- **WHEN** target 配置 `description:` 后不提供值
- **THEN** 系统 SHALL 将该 description 按 null 处理,并接受该配置
#### Scenario: description 为空字符串 #### Scenario: description 为空字符串
- **WHEN** target 配置 `description: ""` - **WHEN** target 配置 `description: ""`
- **THEN** 系统 SHALL 接受该配置,且目标说明为空字符串 - **THEN** 系统 SHALL 接受该配置,且目标说明为空字符串

View File

@@ -44,7 +44,11 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
#### Scenario: 名称列 #### Scenario: 名称列
- **WHEN** 表格渲染 - **WHEN** 表格渲染
- **THEN** 名称列 SHALL 显示目标名称ellipsis 超长名称自动省略并 Tooltip 显示全名,且 SHALL NOT 支持排序 - **THEN** 名称列 SHALL 显示目标展示名称,取值为 `target.name ?? target.id`ellipsis 超长名称自动省略并 Tooltip 显示全名,且 SHALL NOT 支持排序
#### Scenario: name 为 null 的名称列
- **WHEN** 表格渲染某个 `target.name` 为 null 的目标
- **THEN** 名称列 SHALL 显示该目标的 `target.id`
#### Scenario: 类型列 #### Scenario: 类型列
- **WHEN** 表格渲染 - **WHEN** 表格渲染

View File

@@ -110,8 +110,15 @@
], ],
"properties": { "properties": {
"description": { "description": {
"maxLength": 500, "anyOf": [
"type": "string" {
"type": "null"
},
{
"maxLength": 500,
"type": "string"
}
]
}, },
"expect": { "expect": {
"additionalProperties": false, "additionalProperties": false,
@@ -416,9 +423,16 @@
"type": "string" "type": "string"
}, },
"name": { "name": {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "type": "null"
},
{
"maxLength": 30,
"minLength": 1,
"type": "string"
}
]
}, },
"timeout": { "timeout": {
"type": "string" "type": "string"
@@ -511,8 +525,15 @@
], ],
"properties": { "properties": {
"description": { "description": {
"maxLength": 500, "anyOf": [
"type": "string" {
"type": "null"
},
{
"maxLength": 500,
"type": "string"
}
]
}, },
"expect": { "expect": {
"additionalProperties": false, "additionalProperties": false,
@@ -658,9 +679,16 @@
"type": "string" "type": "string"
}, },
"name": { "name": {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "type": "null"
},
{
"maxLength": 30,
"minLength": 1,
"type": "string"
}
]
}, },
"timeout": { "timeout": {
"type": "string" "type": "string"
@@ -720,8 +748,15 @@
], ],
"properties": { "properties": {
"description": { "description": {
"maxLength": 500, "anyOf": [
"type": "string" {
"type": "null"
},
{
"maxLength": 500,
"type": "string"
}
]
}, },
"expect": { "expect": {
"additionalProperties": false, "additionalProperties": false,
@@ -893,9 +928,16 @@
"type": "string" "type": "string"
}, },
"name": { "name": {
"maxLength": 30, "anyOf": [
"minLength": 1, {
"type": "string" "type": "null"
},
{
"maxLength": 30,
"minLength": 1,
"type": "string"
}
]
}, },
"timeout": { "timeout": {
"type": "string" "type": "string"

View File

@@ -179,6 +179,10 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
const nameValue: unknown = raw["name"]; const nameValue: unknown = raw["name"];
const name = isString(nameValue) ? nameValue : id; const name = isString(nameValue) ? nameValue : id;
if (isString(nameValue) && nameValue.trim() === "") {
issues.push(issue("invalid-value", `targets[${i}].name`, "name 不能为空白", name));
}
const type: unknown = raw["type"]; const type: unknown = raw["type"];
if (!isString(type)) { if (!isString(type)) {
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name)); issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));

View File

@@ -192,7 +192,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
group: target.group ?? "default", group: target.group ?? "default",
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id, name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "cmd", type: "cmd",
} satisfies ResolvedCommandTarget; } satisfies ResolvedCommandTarget;

View File

@@ -33,7 +33,7 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase {
expect?: CommandExpectConfig; expect?: CommandExpectConfig;
group: string; group: string;
intervalMs: number; intervalMs: number;
name: string; name: null | string;
timeoutMs: number; timeoutMs: number;
type: "cmd"; type: "cmd";
} }

View File

@@ -185,7 +185,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
group: target.group ?? "default", group: target.group ?? "default",
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id, name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "db", type: "db",
} satisfies ResolvedDbTarget; } satisfies ResolvedDbTarget;

View File

@@ -21,7 +21,7 @@ export interface ResolvedDbTarget extends ResolvedTargetBase {
expect?: DbExpectConfig; expect?: DbExpectConfig;
group: string; group: string;
intervalMs: number; intervalMs: number;
name: string; name: null | string;
timeoutMs: number; timeoutMs: number;
type: "db"; type: "db";
} }

View File

@@ -126,7 +126,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
}, },
id: t.id, id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id, name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "http", type: "http",
} satisfies ResolvedHttpTarget; } satisfies ResolvedHttpTarget;

View File

@@ -51,7 +51,7 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase {
group: string; group: string;
http: ResolvedHttpConfig; http: ResolvedHttpConfig;
intervalMs: number; intervalMs: number;
name: string; name: null | string;
timeoutMs: number; timeoutMs: number;
type: "http"; type: "http";
} }

View File

@@ -49,12 +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 })), description: Type.Optional(Type.Union([Type.Null(), 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({ maxLength: 30, minLength: 1 }), id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema), interval: Type.Optional(durationSchema),
name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })), name: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 30, minLength: 1 })])),
timeout: Type.Optional(durationSchema), timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type), type: Type.Literal(checker.type),
}; };
@@ -69,11 +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 })), description: Type.Optional(Type.Union([Type.Null(), Type.String({ maxLength: 500 })])),
group: Type.Optional(Type.String()), group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }), id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema), interval: Type.Optional(durationSchema),
name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })), name: Type.Optional(Type.Union([Type.Null(), 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[]]),
}, },

View File

@@ -9,7 +9,7 @@ import { checkerRegistry } from "./runner";
const CREATE_TARGETS_TABLE = ` 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,
description TEXT, description TEXT,
type TEXT NOT NULL, type TEXT NOT NULL,
target TEXT NOT NULL, target TEXT NOT NULL,
@@ -309,10 +309,7 @@ export class ProbeStore {
syncTargets(targets: ResolvedTargetBase[]): void { syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return; if (this.closed) return;
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{ const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{ id: string }>;
id: string;
name: string;
}>;
const existingIds = new Set(existingRows.map((r) => r.id)); const existingIds = new Set(existingRows.map((r) => r.id));
const configIds = new Set(targets.map((t) => t.id)); const configIds = new Set(targets.map((t) => t.id));

View File

@@ -41,12 +41,12 @@ export interface ProbeConfig {
export interface RawTargetConfig { export interface RawTargetConfig {
[configKey: string]: unknown; [configKey: string]: unknown;
description?: string; description?: null | string;
expect?: unknown; expect?: unknown;
group?: string; group?: string;
id: string; id: string;
interval?: string; interval?: string;
name?: string; name?: null | string;
timeout?: string; timeout?: string;
type: string; type: string;
} }
@@ -58,7 +58,7 @@ export interface ResolvedTargetBase {
group: string; group: string;
id: string; id: string;
intervalMs: number; intervalMs: number;
name: string; name: null | string;
timeoutMs: number; timeoutMs: number;
type: string; type: string;
} }
@@ -86,7 +86,7 @@ export interface StoredTarget {
grp: string; grp: string;
id: string; id: string;
interval_ms: number; interval_ms: number;
name: string; name: null | string;
target: string; target: string;
timeout_ms: number; timeout_ms: number;
type: string; type: string;

View File

@@ -104,7 +104,7 @@ export interface TargetStatus {
id: string; id: string;
interval: string; interval: string;
latestCheck: CheckResult | null; latestCheck: CheckResult | null;
name: string; name: null | string;
recentSamples: RecentSample[]; recentSamples: RecentSample[];
stats: TargetStats; stats: TargetStats;
target: string; target: string;

View File

@@ -5,6 +5,7 @@ import { DateRangePicker, Drawer, RadioGroup, Space, Tabs, Tag, Typography } fro
import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../shared/api"; import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../shared/api";
import { getTargetDisplayName } from "../utils/target";
import { subtractHours } from "../utils/time"; import { subtractHours } from "../utils/time";
import { HistoryTab } from "./HistoryTab"; import { HistoryTab } from "./HistoryTab";
import { OverviewTab } from "./OverviewTab"; import { OverviewTab } from "./OverviewTab";
@@ -90,7 +91,7 @@ export function TargetDetailDrawer({
target ? ( target ? (
<Space align="center" size={12}> <Space align="center" size={12}>
<StatusDot up={!!isUp} /> <StatusDot up={!!isUp} />
<Typography.Text strong>{target.name}</Typography.Text> <Typography.Text strong>{getTargetDisplayName(target)}</Typography.Text>
<Tag size="small" theme="primary" variant="light-outline"> <Tag size="small" theme="primary" variant="light-outline">
{target.type} {target.type}
</Tag> </Tag>

View File

@@ -6,6 +6,7 @@ import type { TargetStatus } from "../../shared/api";
import { StatusBar } from "../components/StatusBar"; import { StatusBar } from "../components/StatusBar";
import { StatusDot } from "../components/StatusDot"; import { StatusDot } from "../components/StatusDot";
import { getTargetDisplayName } from "../utils/target";
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 } from "./target-table-sorters"; import { availabilitySorter, latencySorter } from "./target-table-sorters";
@@ -22,6 +23,7 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
width: 60, width: 60,
}, },
{ {
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => getTargetDisplayName(row),
colKey: "name", colKey: "name",
ellipsis: true, ellipsis: true,
title: "名称", title: "名称",

5
src/web/utils/target.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { TargetStatus } from "../../shared/api";
export function getTargetDisplayName(target: TargetStatus): string {
return target.name ?? target.id;
}

View File

@@ -234,7 +234,7 @@ targets:
expect(cmd.cmd.maxOutputBytes).toBe(10485760); expect(cmd.cmd.maxOutputBytes).toBe(10485760);
}); });
test("name 缺省时 fallback 到 id", async () => { test("name 缺省时保留为 null", async () => {
const configPath = join(tempDir, "name-fallback.yaml"); const configPath = join(tempDir, "name-fallback.yaml");
await writeFile( await writeFile(
configPath, configPath,
@@ -249,7 +249,105 @@ targets:
const config = await loadConfig(configPath); const config = await loadConfig(configPath);
const target = config.targets[0]!; const target = config.targets[0]!;
expect(target.id).toBe("api-health"); expect(target.id).toBe("api-health");
expect(target.name).toBe("api-health"); expect(target.name).toBeNull();
});
test("name 显式 null 保留为 null", async () => {
const configPath = join(tempDir, "name-explicit-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name: null
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.name).toBeNull();
});
test("name YAML 空值保留为 null", async () => {
const configPath = join(tempDir, "name-yaml-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name:
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.name).toBeNull();
});
test("name 为空字符串抛出错误", async () => {
const configPath = join(tempDir, "empty-name.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name: ""
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
});
test("name 仅包含空白字符抛出错误", async () => {
const configPath = join(tempDir, "whitespace-name.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
name: " "
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("name 不能为空白");
});
test("description 显式 null 保留为 null", async () => {
const configPath = join(tempDir, "description-null.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: null
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBeNull();
});
test("description YAML 空值保留为 null", async () => {
const configPath = join(tempDir, "description-yaml-null.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).toBeNull();
}); });
test("name 支持变量替换且不要求唯一", async () => { test("name 支持变量替换且不要求唯一", async () => {

View File

@@ -209,7 +209,7 @@ describe("ProbeEngine", () => {
}), }),
); );
const mockStore = createMockStore(targets.map((t) => t.name)) as unknown as ProbeStore; const mockStore = createMockStore(targets.map((t) => t.name ?? t.id)) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, targets, 2); const engine = new ProbeEngine(mockStore, targets, 2);
const probeGroup = ( const probeGroup = (

View File

@@ -24,10 +24,6 @@ beforeAll(() => {
ensureRegistered(); ensureRegistered();
}); });
function targetId(store: ProbeStore, name: string): string {
return store.getTargets().find((target) => target.name === name)!.id;
}
const httpTarget: ResolvedHttpTarget = { const httpTarget: ResolvedHttpTarget = {
description: null, description: null,
expect: { maxDurationMs: 3000, status: [200] }, expect: { maxDurationMs: 3000, status: [200] },
@@ -87,11 +83,11 @@ describe("ProbeStore", () => {
store.syncTargets([httpTarget, commandTarget]); store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets(); const targets = store.getTargets();
expect(targets).toHaveLength(2); expect(targets).toHaveLength(2);
expect(targets.map((target) => target.name).sort()).toEqual(["test-cmd", "test-http"]); expect(targets.map((target) => target.id).sort()).toEqual(["test-cmd", "test-http"]);
}); });
test("http target 字段正确", () => { test("http target 字段正确", () => {
const t = store.getTargets().find((t) => t.name === "test-http")!; const t = store.getTargets().find((t) => t.id === "test-http")!;
expect(t.type).toBe("http"); expect(t.type).toBe("http");
expect(t.target).toBe("https://example.com/health"); expect(t.target).toBe("https://example.com/health");
const config = JSON.parse(t.config) as { const config = JSON.parse(t.config) as {
@@ -114,7 +110,7 @@ describe("ProbeStore", () => {
}); });
test("cmd target 字段正确", () => { test("cmd target 字段正确", () => {
const t = store.getTargets().find((t) => t.name === "test-cmd")!; const t = store.getTargets().find((t) => t.id === "test-cmd")!;
expect(t.type).toBe("cmd"); expect(t.type).toBe("cmd");
expect(t.target).toBe("exec ping -c 1 localhost"); expect(t.target).toBe("exec ping -c 1 localhost");
const config = JSON.parse(t.config) as { args: string[]; cwd: string; exec: string; maxOutputBytes: number }; const config = JSON.parse(t.config) as { args: string[]; cwd: string; exec: string; maxOutputBytes: number };
@@ -133,7 +129,7 @@ describe("ProbeStore", () => {
http: { ...httpTarget.http, url: "https://example.com/v2" }, http: { ...httpTarget.http, url: "https://example.com/v2" },
}; };
store.syncTargets([updated, commandTarget]); store.syncTargets([updated, commandTarget]);
const t = store.getTargets().find((t) => t.name === "test-http")!; const t = store.getTargets().find((t) => t.id === "test-http")!;
expect(t.target).toBe("https://example.com/v2"); expect(t.target).toBe("https://example.com/v2");
expect(store.getTargets()).toHaveLength(2); expect(store.getTargets()).toHaveLength(2);
}); });
@@ -151,7 +147,7 @@ describe("ProbeStore", () => {
}); });
test("getTargetById", () => { test("getTargetById", () => {
const found = store.getTargetById(targetId(store, "test-http")); const found = store.getTargetById("test-http");
expect(found).toBeDefined(); expect(found).toBeDefined();
expect(found!.name).toBe("test-http"); expect(found!.name).toBe("test-http");
}); });
@@ -162,14 +158,13 @@ describe("ProbeStore", () => {
test("写入 check result 并查询", () => { test("写入 check result 并查询", () => {
store.syncTargets([httpTarget, commandTarget]); store.syncTargets([httpTarget, commandTarget]);
const t1Id = targetId(store, "test-http");
store.insertCheckResult({ store.insertCheckResult({
durationMs: 150.5, durationMs: 150.5,
failure: null, failure: null,
matched: true, matched: true,
statusDetail: "200 OK", statusDetail: "200 OK",
targetId: t1Id, targetId: "test-http",
timestamp: "2025-01-01T00:00:00.000Z", timestamp: "2025-01-01T00:00:00.000Z",
}); });
@@ -178,7 +173,7 @@ describe("ProbeStore", () => {
failure: null, failure: null,
matched: true, matched: true,
statusDetail: "200 OK", statusDetail: "200 OK",
targetId: t1Id, targetId: "test-http",
timestamp: "2025-01-01T00:00:30.000Z", timestamp: "2025-01-01T00:00:30.000Z",
}); });
@@ -196,15 +191,15 @@ describe("ProbeStore", () => {
failure, failure,
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetId: t1Id, targetId: "test-http",
timestamp: "2025-01-01T00:01:00.000Z", timestamp: "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); const history = store.getHistory("test-http", "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z", 1, 10);
expect(history.items).toHaveLength(3); expect(history.items).toHaveLength(3);
expect(history.items[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z"); expect(history.items[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z");
const latest = store.getLatestCheck(t1Id)!; const latest = store.getLatestCheck("test-http")!;
expect(latest.matched).toBe(0); expect(latest.matched).toBe(0);
expect(latest.failure).not.toBeNull(); expect(latest.failure).not.toBeNull();
const parsedFailure = JSON.parse(latest.failure!) as CheckFailure; const parsedFailure = JSON.parse(latest.failure!) as CheckFailure;
@@ -214,27 +209,23 @@ describe("ProbeStore", () => {
}); });
test("getHistory 默认 limit=20", () => { test("getHistory 默认 limit=20", () => {
const t1Id = targetId(store, "test-http");
for (let i = 0; i < 25; i++) { for (let i = 0; i < 25; i++) {
store.insertCheckResult({ store.insertCheckResult({
durationMs: 100 + i, durationMs: 100 + i,
failure: null, failure: null,
matched: true, matched: true,
statusDetail: "200 OK", statusDetail: "200 OK",
targetId: t1Id, targetId: "test-http",
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`, timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
}); });
} }
const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z"); const history = store.getHistory("test-http", "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z");
expect(history.items).toHaveLength(20); expect(history.items).toHaveLength(20);
}); });
test("getTargetWindowStats 按时间窗口计算基础计数", () => { test("getTargetWindowStats 按时间窗口计算基础计数", () => {
const t1Id = targetId(store, "test-http"); const stats = store.getTargetWindowStats("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBeGreaterThan(0); expect(stats.totalChecks).toBeGreaterThan(0);
expect(stats.upChecks + stats.downChecks).toBe(stats.totalChecks); expect(stats.upChecks + stats.downChecks).toBe(stats.totalChecks);
expect(stats.availability).toBeGreaterThanOrEqual(0); expect(stats.availability).toBeGreaterThanOrEqual(0);
@@ -242,9 +233,7 @@ describe("ProbeStore", () => {
}); });
test("无记录目标的窗口 stats", () => { test("无记录目标的窗口 stats", () => {
const t2Id = targetId(store, "test-cmd"); const stats = store.getTargetWindowStats("test-cmd", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBe(0); expect(stats.totalChecks).toBe(0);
expect(stats.upChecks).toBe(0); expect(stats.upChecks).toBe(0);
expect(stats.downChecks).toBe(0); expect(stats.downChecks).toBe(0);
@@ -253,16 +242,14 @@ describe("ProbeStore", () => {
test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => { test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => {
const latestChecksMap = store.getLatestChecksMap(); const latestChecksMap = store.getLatestChecksMap();
const latest = latestChecksMap.get(targetId(store, "test-http")); const latest = latestChecksMap.get("test-http");
expect(latest).toBeDefined(); expect(latest).toBeDefined();
expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z"); expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z");
}); });
test("getTargetCheckpoints 返回窗口内升序检查点", () => { test("getTargetCheckpoints 返回窗口内升序检查点", () => {
const t1Id = targetId(store, "test-http"); const checkpoints = store.getTargetCheckpoints("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(checkpoints).toEqual([ expect(checkpoints).toEqual([
{ duration_ms: 150.5, matched: 1, timestamp: "2025-01-01T00:00:00.000Z" }, { duration_ms: 150.5, matched: 1, timestamp: "2025-01-01T00:00:00.000Z" },
{ duration_ms: 300, matched: 1, timestamp: "2025-01-01T00:00:30.000Z" }, { duration_ms: 300, matched: 1, timestamp: "2025-01-01T00:00:30.000Z" },
@@ -271,16 +258,12 @@ describe("ProbeStore", () => {
}); });
test("getTargetDurations 返回成功检查耗时升序数组", () => { test("getTargetDurations 返回成功检查耗时升序数组", () => {
const t1Id = targetId(store, "test-http"); const durations = store.getTargetDurations("test-http", "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(durations).toEqual([150.5, 300]); expect(durations).toEqual([150.5, 300]);
}); });
test("getRecentSamples 返回最近采样数据", () => { test("getRecentSamples 返回最近采样数据", () => {
const t1Id = targetId(store, "test-http"); const samples = store.getRecentSamples("test-http", 10);
const samples = store.getRecentSamples(t1Id, 10);
expect(Array.isArray(samples)).toBe(true); expect(Array.isArray(samples)).toBe(true);
expect(samples.length).toBeGreaterThan(0); expect(samples.length).toBeGreaterThan(0);
for (const sample of samples) { for (const sample of samples) {
@@ -306,9 +289,9 @@ describe("ProbeStore", () => {
}; };
sampleStore.syncTargets([httpA, httpB, httpEmpty]); sampleStore.syncTargets([httpA, httpB, httpEmpty]);
const targets = sampleStore.getTargets(); const targets = sampleStore.getTargets();
const targetAId = targets.find((t) => t.name === "sample-http-a")!.id; const targetAId = targets.find((t) => t.id === "sample-http-a")!.id;
const targetBId = targets.find((t) => t.name === "sample-http-b")!.id; const targetBId = targets.find((t) => t.id === "sample-http-b")!.id;
const emptyTargetId = targets.find((t) => t.name === "sample-http-empty")!.id; const emptyTargetId = targets.find((t) => t.id === "sample-http-empty")!.id;
for (const [index, timestamp] of [ for (const [index, timestamp] of [
"2025-01-01T00:00:00.000Z", "2025-01-01T00:00:00.000Z",
@@ -455,19 +438,16 @@ describe("ProbeStore", () => {
}); });
test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => { test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => {
const t1Id = targetId(store, "test-http");
const t2Id = targetId(store, "test-cmd");
const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z"); const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z");
expect(stats).toBeInstanceOf(Map); expect(stats).toBeInstanceOf(Map);
const stats1 = stats.get(t1Id); const stats1 = stats.get("test-http");
expect(stats1).toBeDefined(); expect(stats1).toBeDefined();
expect(stats1!.totalChecks).toBeGreaterThan(0); expect(stats1!.totalChecks).toBeGreaterThan(0);
expect(stats1!.upChecks + stats1!.downChecks).toBe(stats1!.totalChecks); expect(stats1!.upChecks + stats1!.downChecks).toBe(stats1!.totalChecks);
expect(stats1!.availability).toBeGreaterThanOrEqual(0); expect(stats1!.availability).toBeGreaterThanOrEqual(0);
const stats2 = stats.get(t2Id); const stats2 = stats.get("test-cmd");
if (stats2) { if (stats2) {
expect(stats2.totalChecks).toBe(0); expect(stats2.totalChecks).toBe(0);
expect(stats2.availability).toBe(0); expect(stats2.availability).toBe(0);
@@ -548,8 +528,8 @@ describe("ProbeStore", () => {
}; };
incidentStore.syncTargets([httpA, httpB]); incidentStore.syncTargets([httpA, httpB]);
const targets = incidentStore.getTargets(); const targets = incidentStore.getTargets();
const targetAId = targets.find((target) => target.name === "incident-http-a")!.id; const targetAId = targets.find((target) => target.id === "incident-http-a")!.id;
const targetBId = targets.find((target) => target.name === "incident-http-b")!.id; const targetBId = targets.find((target) => target.id === "incident-http-b")!.id;
incidentStore.insertCheckResult({ incidentStore.insertCheckResult({
durationMs: 100, durationMs: 100,
@@ -698,4 +678,12 @@ describe("ProbeStore", () => {
expect(t.description).toBe("新描述"); expect(t.description).toBe("新描述");
updateDescStore.close(); updateDescStore.close();
}); });
test("name 为 null 时持久化为 null", () => {
const nullNameStore = new ProbeStore(join(tempDir, "null-name.db"));
nullNameStore.syncTargets([{ ...httpTarget, id: "null-name", name: null }]);
const t = nullNameStore.getTargets()[0]!;
expect(t.name).toBeNull();
nullNameStore.close();
});
}); });

View File

@@ -121,4 +121,10 @@ describe("TargetDetailDrawer", () => {
const dragLine = wrapper.querySelector('[style*="col-resize"]'); const dragLine = wrapper.querySelector('[style*="col-resize"]');
expect(dragLine).not.toBeNull(); expect(dragLine).not.toBeNull();
}); });
test("name 为 null 时标题显示 id", () => {
const nullNameTarget = { ...target, name: null };
render(<TargetDetailDrawer {...defaultProps} target={nullNameTarget} />);
expect(document.body.textContent).toContain("1");
});
}); });

View File

@@ -156,4 +156,28 @@ describe("createTargetTableColumns", () => {
expect(nameColumn.sorter).toBeUndefined(); expect(nameColumn.sorter).toBeUndefined();
expect(nameColumn.sortType).toBeUndefined(); expect(nameColumn.sortType).toBeUndefined();
}); });
test("名称列 name 为 null 时显示 id", () => {
const nameColumn = getColumn(createTargetTableColumns(["http"]), "name");
const renderCell = nameColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => string;
const result = renderCell({
col: nameColumn,
colIndex: 1,
row: makeTarget({ id: "my-api", name: null }),
rowIndex: 0,
});
expect(result).toBe("my-api");
});
test("名称列 name 有值时显示 name", () => {
const nameColumn = getColumn(createTargetTableColumns(["http"]), "name");
const renderCell = nameColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => string;
const result = renderCell({
col: nameColumn,
colIndex: 1,
row: makeTarget({ id: "my-api", name: "我的 API" }),
rowIndex: 0,
});
expect(result).toBe("我的 API");
});
}); });

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { TargetStatus } from "../../../src/shared/api";
import { getTargetDisplayName } from "../../../src/web/utils/target";
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return {
currentStreak: null,
description: null,
group: "default",
id: "api-health",
interval: "30s",
latestCheck: null,
name: null,
recentSamples: [],
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
target: "https://example.com",
type: "http",
...overrides,
};
}
describe("getTargetDisplayName", () => {
test("name 为 null 时返回 id", () => {
expect(getTargetDisplayName(makeTarget())).toBe("api-health");
});
test("name 有值时返回 name", () => {
expect(getTargetDisplayName(makeTarget({ name: "我的 API" }))).toBe("我的 API");
});
});