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**
- `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
- 复合索引:`(target_id, timestamp)`

View File

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

View File

@@ -17,7 +17,11 @@
#### Scenario: 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 字段
- **WHEN** 某个 target 配置了 `description`
@@ -109,7 +113,7 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态
#### Scenario: 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 类型
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型

View File

@@ -5,7 +5,7 @@
## Requirements
### 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` 字段。
@@ -15,11 +15,11 @@
#### Scenario: 最简 HTTP 配置文件解析
- **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 配置文件解析
- **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 配置覆盖全局默认值
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
@@ -35,7 +35,7 @@
#### Scenario: 最简 db 配置文件解析
- **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 触发校验错误
- **WHEN** 配置文件中出现 `defaults.http.method` 字段
@@ -330,18 +330,30 @@
- **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 为空字符串。
系统 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 字段解析
- **WHEN** 系统读取包含 `description: "检查生产 API 健康状态"` 的 target
- **THEN** 系统 SHALL 将该字段解析为 target 的目标说明
#### Scenario: name 为 null 通过校验
- **WHEN** 系统读取包含 `name: null` 或省略 `name` 的 target
- **THEN** 系统 SHALL 接受该配置
#### Scenario: name 仅包含空白字符报错
- **WHEN** 系统读取包含 `name: " "` 的 target
- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空白
#### Scenario: description 为空字符串
- **WHEN** 系统读取包含 `description: ""` 的 target
- **THEN** 系统 SHALL 接受该配置,且不触发长度错误
#### Scenario: description 为 null 通过校验
- **WHEN** 系统读取包含 `description: null` 或省略 `description` 的 target
- **THEN** 系统 SHALL 接受该配置
#### Scenario: description 类型非法
- **WHEN** YAML 中某个 target 的 `description` 字段不是字符串
- **WHEN** YAML 中某个 target 的 `description` 字段不是字符串也不是 null
- **THEN** 系统 SHALL 以错误退出,提示 description 字段类型错误
#### Scenario: description 超过最大长度
@@ -361,12 +373,12 @@
- **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误
### Requirement: 配置 schema 导出包含 target 元信息约束
系统 SHALL 在导出的 `probe-config.schema.json` 中包含 target `id``name``description` 的长度约束,用于编辑器提示和外部校验。
系统 SHALL 在导出的 `probe-config.schema.json` 中包含 target `id``name``description` 的长度约束和可空类型,用于编辑器提示和外部校验。
#### Scenario: schema 导出 description
- **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`
- **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
### 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: 首次启动创建数据库
- **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 列
#### Scenario: targets name 列允许 NULL
- **WHEN** 系统首次创建 targets 表
- **THEN** targets.name 列 SHALL 允许存储 NULL
#### Scenario: 数据目录不存在
- **WHEN** 配置的数据目录路径不存在
- **THEN** 系统 SHALL 自动创建该目录
@@ -26,20 +30,32 @@
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
### Requirement: targets 表同步
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示名称元信息、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。
#### Scenario: 首次同步目标
- **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: 配置变更后重新同步
- **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
- **WHEN** YAML target 未配置 `description`
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
#### Scenario: description 显式 null
- **WHEN** YAML target 配置 `description: null`
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
### Requirement: check_results 表追加写入
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。

View File

@@ -13,7 +13,11 @@ Dashboard SHALL 在用户点击目标表格行后从右侧滑出 Drawer展示
#### Scenario: 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
- **WHEN** 用户点击关闭按钮、ESC 键或遮罩层

View File

@@ -36,23 +36,39 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复
### 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
- **WHEN** target 配置 `id: "api-health"``name: "API 健康检查"`
- **THEN** 系统 SHALL 使用 "API 健康检查" 作为展示名称
- **THEN** 系统 SHALL 在解析后保留 name 为 "API 健康检查"
#### Scenario: name 使用变量
- **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`
- **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 为空字符串报错
- **WHEN** target 配置 `name: ""`
- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法
- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空
#### Scenario: name 仅包含空白字符报错
- **WHEN** target 配置 `name: " "`
- **THEN** 系统 SHALL 以配置错误退出,提示 name 不能为空
#### Scenario: name 超过最大长度报错
- **WHEN** target 配置超过 30 个字符的 `name`
@@ -63,7 +79,7 @@
- **THEN** 系统 SHALL 接受该配置不报错name 不要求全局唯一)
### 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
- **WHEN** target 配置 `description: "检查生产 API 健康状态"`
@@ -77,6 +93,14 @@
- **WHEN** target 未配置 `description`
- **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 为空字符串
- **WHEN** target 配置 `description: ""`
- **THEN** 系统 SHALL 接受该配置,且目标说明为空字符串

View File

@@ -44,7 +44,11 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
#### Scenario: 名称列
- **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: 类型列
- **WHEN** 表格渲染

View File

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

View File

@@ -179,6 +179,10 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
const nameValue: unknown = raw["name"];
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"];
if (!isString(type)) {
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",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? t.id,
name: t.name ?? null,
timeoutMs: context.defaultTimeoutMs,
type: "cmd",
} satisfies ResolvedCommandTarget;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,12 +49,12 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
export function createTargetSchema(checker: CheckerDefinition): 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),
group: Type.Optional(Type.String()),
id: Type.String({ maxLength: 30, minLength: 1 }),
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),
type: Type.Literal(checker.type),
};
@@ -69,11 +69,11 @@ function cloneSchema(schema: TSchema): Record<string, unknown> {
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
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()),
id: Type.String({ maxLength: 30, minLength: 1 }),
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),
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 = `
CREATE TABLE IF NOT EXISTS targets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
name TEXT,
description TEXT,
type TEXT NOT NULL,
target TEXT NOT NULL,
@@ -309,10 +309,7 @@ export class ProbeStore {
syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return;
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{
id: string;
name: string;
}>;
const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{ id: string }>;
const existingIds = new Set(existingRows.map((r) => r.id));
const configIds = new Set(targets.map((t) => t.id));

View File

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

View File

@@ -104,7 +104,7 @@ export interface TargetStatus {
id: string;
interval: string;
latestCheck: CheckResult | null;
name: string;
name: null | string;
recentSamples: RecentSample[];
stats: TargetStats;
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 { getTargetDisplayName } from "../utils/target";
import { subtractHours } from "../utils/time";
import { HistoryTab } from "./HistoryTab";
import { OverviewTab } from "./OverviewTab";
@@ -90,7 +91,7 @@ export function TargetDetailDrawer({
target ? (
<Space align="center" size={12}>
<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">
{target.type}
</Tag>

View File

@@ -6,6 +6,7 @@ import type { TargetStatus } from "../../shared/api";
import { StatusBar } from "../components/StatusBar";
import { StatusDot } from "../components/StatusDot";
import { getTargetDisplayName } from "../utils/target";
import { getAvailabilityProgressColor } from "./color-threshold";
import { statusFilter } from "./target-table-filters";
import { availabilitySorter, latencySorter } from "./target-table-sorters";
@@ -22,6 +23,7 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
width: 60,
},
{
cell: ({ row }: PrimaryTableCellParams<TargetStatus>) => getTargetDisplayName(row),
colKey: "name",
ellipsis: true,
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);
});
test("name 缺省时 fallback 到 id", async () => {
test("name 缺省时保留为 null", async () => {
const configPath = join(tempDir, "name-fallback.yaml");
await writeFile(
configPath,
@@ -249,7 +249,105 @@ targets:
const config = await loadConfig(configPath);
const target = config.targets[0]!;
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 () => {

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 probeGroup = (

View File

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