diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e7f2826..977731a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -463,7 +463,7 @@ TcpChecker implements Checker **Schema**: -- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(展示名称)、description(描述)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp +- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(TEXT,可 NULL,展示名称)、description(TEXT,可 NULL,描述)、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) - 复合索引:`(target_id, timestamp)` diff --git a/README.md b/README.md index 5c42157..33f34b8 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md index d7d3173..363afe7 100644 --- a/openspec/specs/probe-api/spec.md +++ b/openspec/specs/probe-api/spec.md @@ -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)、stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段,其中 description 类型为 null 或字符串 +- **THEN** 该类型 SHALL 包含目标基本信息字段(id、name、description、group、type、target、interval)、stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段,其中 name 和 description 类型均为 null 或字符串 #### Scenario: TargetMetricsResponse 类型 - **WHEN** 前后端共享 `TargetMetricsResponse` 类型 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 83260bf..1f2e67c 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -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 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index 5fd6c55..4e21d42 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/probe-data-store/spec.md @@ -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 表包含 id(INTEGER PRIMARY KEY AUTOINCREMENT)、target_id(INTEGER NOT NULL)、timestamp(TEXT NOT NULL)、matched(INTEGER NOT NULL)、duration_ms(REAL)、status_detail(TEXT)、failure(TEXT),不包含 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 表,不更新或删除已有记录。 diff --git a/openspec/specs/target-detail-drawer/spec.md b/openspec/specs/target-detail-drawer/spec.md index 3a8a9d9..960d795 100644 --- a/openspec/specs/target-detail-drawer/spec.md +++ b/openspec/specs/target-detail-drawer/spec.md @@ -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 键或遮罩层 diff --git a/openspec/specs/target-identity/spec.md b/openspec/specs/target-identity/spec.md index 07f131e..971b472 100644 --- a/openspec/specs/target-identity/spec.md +++ b/openspec/specs/target-identity/spec.md @@ -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 接受该配置,且目标说明为空字符串 diff --git a/openspec/specs/target-table/spec.md b/openspec/specs/target-table/spec.md index 51a69e0..22451ec 100644 --- a/openspec/specs/target-table/spec.md +++ b/openspec/specs/target-table/spec.md @@ -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** 表格渲染 diff --git a/probe-config.schema.json b/probe-config.schema.json index 24b02c4..08efad5 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -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" diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index d5190a5..2a99c1c 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -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)); diff --git a/src/server/checker/runner/cmd/execute.ts b/src/server/checker/runner/cmd/execute.ts index f9d342a..f78107f 100644 --- a/src/server/checker/runner/cmd/execute.ts +++ b/src/server/checker/runner/cmd/execute.ts @@ -192,7 +192,7 @@ export class CommandChecker implements CheckerDefinition 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; diff --git a/src/server/checker/runner/cmd/types.ts b/src/server/checker/runner/cmd/types.ts index 03460c7..bd0aaa0 100644 --- a/src/server/checker/runner/cmd/types.ts +++ b/src/server/checker/runner/cmd/types.ts @@ -33,7 +33,7 @@ export interface ResolvedCommandTarget extends ResolvedTargetBase { expect?: CommandExpectConfig; group: string; intervalMs: number; - name: string; + name: null | string; timeoutMs: number; type: "cmd"; } diff --git a/src/server/checker/runner/db/execute.ts b/src/server/checker/runner/db/execute.ts index 6ffccc5..0c9fcf1 100644 --- a/src/server/checker/runner/db/execute.ts +++ b/src/server/checker/runner/db/execute.ts @@ -185,7 +185,7 @@ export class DbChecker implements CheckerDefinition { 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; diff --git a/src/server/checker/runner/db/types.ts b/src/server/checker/runner/db/types.ts index cc2e596..145e027 100644 --- a/src/server/checker/runner/db/types.ts +++ b/src/server/checker/runner/db/types.ts @@ -21,7 +21,7 @@ export interface ResolvedDbTarget extends ResolvedTargetBase { expect?: DbExpectConfig; group: string; intervalMs: number; - name: string; + name: null | string; timeoutMs: number; type: "db"; } diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index ee278da..cc778b9 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -126,7 +126,7 @@ export class HttpChecker implements CheckerDefinition { }, id: t.id, intervalMs: context.defaultIntervalMs, - name: t.name ?? t.id, + name: t.name ?? null, timeoutMs: context.defaultTimeoutMs, type: "http", } satisfies ResolvedHttpTarget; diff --git a/src/server/checker/runner/http/types.ts b/src/server/checker/runner/http/types.ts index de6f757..939b5f1 100644 --- a/src/server/checker/runner/http/types.ts +++ b/src/server/checker/runner/http/types.ts @@ -51,7 +51,7 @@ export interface ResolvedHttpTarget extends ResolvedTargetBase { group: string; http: ResolvedHttpConfig; intervalMs: number; - name: string; + name: null | string; timeoutMs: number; type: "http"; } diff --git a/src/server/checker/schema/builder.ts b/src/server/checker/schema/builder.ts index 241acab..e91993a 100644 --- a/src/server/checker/schema/builder.ts +++ b/src/server/checker/schema/builder.ts @@ -49,12 +49,12 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external export function createTargetSchema(checker: CheckerDefinition): TSchema { const properties: Record = { - 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 { 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[]]), }, diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 458fc5a..9cde4ce 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -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)); diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index 459eb82..67de2d8 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -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; diff --git a/src/shared/api.ts b/src/shared/api.ts index bcd0da1..5a0a824 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -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; diff --git a/src/web/components/TargetDetailDrawer.tsx b/src/web/components/TargetDetailDrawer.tsx index cc9fc3b..fb11265 100644 --- a/src/web/components/TargetDetailDrawer.tsx +++ b/src/web/components/TargetDetailDrawer.tsx @@ -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 ? ( - {target.name} + {getTargetDisplayName(target)} {target.type} diff --git a/src/web/constants/target-table-columns.tsx b/src/web/constants/target-table-columns.tsx index 4e4c6eb..7a1c144 100644 --- a/src/web/constants/target-table-columns.tsx +++ b/src/web/constants/target-table-columns.tsx @@ -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) => getTargetDisplayName(row), colKey: "name", ellipsis: true, title: "名称", diff --git a/src/web/utils/target.ts b/src/web/utils/target.ts new file mode 100644 index 0000000..0eb8092 --- /dev/null +++ b/src/web/utils/target.ts @@ -0,0 +1,5 @@ +import type { TargetStatus } from "../../shared/api"; + +export function getTargetDisplayName(target: TargetStatus): string { + return target.name ?? target.id; +} diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index c855002..8c6d58d 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -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 () => { diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index 9853b56..ef59bde 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -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 = ( diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index 2c4a1aa..a5e3283 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -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(); + }); }); diff --git a/tests/web/components/TargetDetailDrawer.test.tsx b/tests/web/components/TargetDetailDrawer.test.tsx index 92fe065..2e5b9b7 100644 --- a/tests/web/components/TargetDetailDrawer.test.tsx +++ b/tests/web/components/TargetDetailDrawer.test.tsx @@ -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(); + expect(document.body.textContent).toContain("1"); + }); }); diff --git a/tests/web/constants/target-table-columns.test.ts b/tests/web/constants/target-table-columns.test.ts index f61cf4b..e7e498a 100644 --- a/tests/web/constants/target-table-columns.test.ts +++ b/tests/web/constants/target-table-columns.test.ts @@ -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) => 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) => string; + const result = renderCell({ + col: nameColumn, + colIndex: 1, + row: makeTarget({ id: "my-api", name: "我的 API" }), + rowIndex: 0, + }); + expect(result).toBe("我的 API"); + }); }); diff --git a/tests/web/utils/target.test.ts b/tests/web/utils/target.test.ts new file mode 100644 index 0000000..714bd43 --- /dev/null +++ b/tests/web/utils/target.test.ts @@ -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 { + 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"); + }); +});