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:
@@ -463,7 +463,7 @@ TcpChecker implements Checker
|
|||||||
|
|
||||||
**Schema**:
|
**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)
|
- `check_results` 表:target_id(TEXT FK CASCADE,引用配置 target id)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON)
|
||||||
- 复合索引:`(target_id, timestamp)`
|
- 复合索引:`(target_id, timestamp)`
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -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`)
|
||||||
|
|
||||||
|
|||||||
@@ -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)、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 类型
|
#### Scenario: TargetMetricsResponse 类型
|
||||||
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型
|
- **WHEN** 前后端共享 `TargetMetricsResponse` 类型
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 表包含 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 列
|
- **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: 数据目录不存在
|
#### 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 表,不更新或删除已有记录。
|
||||||
|
|
||||||
|
|||||||
@@ -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 键或遮罩层
|
||||||
|
|||||||
@@ -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 接受该配置,且目标说明为空字符串
|
||||||
|
|||||||
@@ -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** 表格渲染
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]]),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
5
src/web/utils/target.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { TargetStatus } from "../../shared/api";
|
||||||
|
|
||||||
|
export function getTargetDisplayName(target: TargetStatus): string {
|
||||||
|
return target.name ?? target.id;
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
32
tests/web/utils/target.test.ts
Normal file
32
tests/web/utils/target.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user