1
0

feat: 新增 target description 字段,收紧 id/name 长度,调整延迟列和名称列

This commit is contained in:
2026-05-17 18:42:46 +08:00
parent 7926514986
commit f7193e98ff
36 changed files with 385 additions and 58 deletions

View File

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

View File

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

View File

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

View File

@@ -328,3 +328,45 @@
#### Scenario: dataDir 使用默认值 #### Scenario: dataDir 使用默认值
- **WHEN** 未配置 `server.dataDir`(使用默认值 `./data` - **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`
- **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径 - **THEN** 系统 SHALL 将默认值 `./data` 基于 configDir 解析为绝对路径
### Requirement: target 通用元信息字段约束
系统 SHALL 在 YAML target 通用字段中支持 `description` 字段,并对 `id``name``description` 执行契约校验。`id` MUST 为 1 到 30 个字符,显式 `name` MUST 为 1 到 30 个字符,`description` MUST 不超过 500 个字符且 MAY 为空字符串。
#### Scenario: description 字段解析
- **WHEN** 系统读取包含 `description: "检查生产 API 健康状态"` 的 target
- **THEN** 系统 SHALL 将该字段解析为 target 的目标说明
#### Scenario: description 为空字符串
- **WHEN** 系统读取包含 `description: ""` 的 target
- **THEN** 系统 SHALL 接受该配置,且不触发长度错误
#### Scenario: description 类型非法
- **WHEN** YAML 中某个 target 的 `description` 字段不是字符串
- **THEN** 系统 SHALL 以错误退出,提示 description 字段类型错误
#### Scenario: description 超过最大长度
- **WHEN** YAML 中某个 target 的 `description` 字段超过 500 个字符
- **THEN** 系统 SHALL 以错误退出,提示 description 字段长度错误
#### Scenario: id 超过最大长度
- **WHEN** YAML 中某个 target 的 `id` 字段超过 30 个字符
- **THEN** 系统 SHALL 以错误退出,提示 id 字段长度错误
#### Scenario: name 超过最大长度
- **WHEN** YAML 中某个 target 的 `name` 字段超过 30 个字符
- **THEN** 系统 SHALL 以错误退出,提示 name 字段长度错误
#### Scenario: 变量替换后 description 超长
- **WHEN** target 的 `description` 通过变量替换后超过 500 个字符
- **THEN** 系统 SHALL 在契约校验阶段以错误退出,提示 description 字段长度错误
### Requirement: 配置 schema 导出包含 target 元信息约束
系统 SHALL 在导出的 `probe-config.schema.json` 中包含 target `id``name``description` 的长度约束,用于编辑器提示和外部校验。
#### Scenario: schema 导出 description
- **WHEN** 系统导出 `probe-config.schema.json`
- **THEN** target schema SHALL 包含可选的 `description` 字段,类型为 string最大长度为 500
#### Scenario: schema 导出 id 和 name 长度
- **WHEN** 系统导出 `probe-config.schema.json`
- **THEN** target schema SHALL 声明 `id` 的 minLength 为 1、maxLength 为 30并声明 `name` 的 minLength 为 1、maxLength 为 30

View File

@@ -26,15 +26,19 @@
- **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录 - **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录
### Requirement: targets 表同步 ### Requirement: targets 表同步
系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置分组信息。 系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置分组信息和目标说明
#### Scenario: 首次同步目标 #### Scenario: 首次同步目标
- **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target - **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target
- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms、expect 和 grp - **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect 和 grp
#### Scenario: 配置变更后重新同步 #### Scenario: 配置变更后重新同步
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启 - **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段) - **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 description 和 grp 字段)
#### Scenario: 未配置 description
- **WHEN** YAML target 未配置 `description`
- **THEN** targets 表 SHALL 将该目标的 description 存储为 NULL
### Requirement: check_results 表追加写入 ### Requirement: check_results 表追加写入
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。 系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。

View File

@@ -215,7 +215,11 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
#### Scenario: 基本信息内容 #### Scenario: 基本信息内容
- **WHEN** 概览面板渲染 - **WHEN** 概览面板渲染
- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情 - **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情、描述,其中描述 SHALL 位于最后一行
#### Scenario: 描述行占满整行
- **WHEN** 概览面板渲染基本信息
- **THEN** 描述项 SHALL 占据 Descriptions 的一整行,内容 SHALL 使用 `target.description ?? ""`,即使 description 为空也 SHALL 渲染该项
#### Scenario: 统计区上下布局卡片 #### Scenario: 统计区上下布局卡片
- **WHEN** 概览面板渲染且有统计数据 - **WHEN** 概览面板渲染且有统计数据

View File

@@ -5,7 +5,7 @@
## Requirements ## Requirements
### Requirement: target id 字段 ### Requirement: target id 字段
每个 target SHALL 包含必填的 `id` 字段作为唯一标识符。`id` SHALL 符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则。`id` MUST 在所有 targets 中全局唯一。`id` MUST NOT 参与变量替换。 每个 target SHALL 包含必填的 `id` 字段作为唯一标识符。`id` SHALL 符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则。`id` 长度 MUST 为 1 到 30 个字符。`id` MUST 在所有 targets 中全局唯一。`id` MUST NOT 参与变量替换。
#### Scenario: 合法 id #### Scenario: 合法 id
- **WHEN** target 配置 `id: "api-health"` - **WHEN** target 配置 `id: "api-health"`
@@ -23,6 +23,10 @@
- **WHEN** target 配置 `id: ""` - **WHEN** target 配置 `id: ""`
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空 - **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空
#### Scenario: id 超过最大长度报错
- **WHEN** target 配置超过 30 个字符的 `id`
- **THEN** 系统 SHALL 以配置错误退出,提示 id 长度不合法
#### Scenario: id 不合法报错 #### Scenario: id 不合法报错
- **WHEN** target 配置 `id: "_invalid"``id: "-start"``id: "has space"` - **WHEN** target 配置 `id: "_invalid"``id: "-start"``id: "has space"`
- **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则 - **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则
@@ -32,7 +36,7 @@
- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复 - **THEN** 系统 SHALL 以配置错误退出,提示 id 重复
### Requirement: target name 字段 ### Requirement: target name 字段
每个 target SHALL 支持可选的 `name` 字段作为展示名称。`name` 缺省时 SHALL fallback 到 `id` 的值作为展示名称。`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一。 每个 target SHALL 支持可选的 `name` 字段作为展示名称。`name` 缺省时 SHALL fallback 到 `id` 的值作为展示名称。显式配置的 `name` 长度 MUST 为 1 到 30 个字符。`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一。
#### Scenario: 配置 name #### Scenario: 配置 name
- **WHEN** target 配置 `id: "api-health"``name: "API 健康检查"` - **WHEN** target 配置 `id: "api-health"``name: "API 健康检查"`
@@ -46,6 +50,37 @@
- **WHEN** target 配置 `id: "api-health"` 但未配置 `name` - **WHEN** target 配置 `id: "api-health"` 但未配置 `name`
- **THEN** 系统 SHALL 使用 "api-health" 作为展示名称 - **THEN** 系统 SHALL 使用 "api-health" 作为展示名称
#### Scenario: name 为空字符串报错
- **WHEN** target 配置 `name: ""`
- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法
#### Scenario: name 超过最大长度报错
- **WHEN** target 配置超过 30 个字符的 `name`
- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法
#### Scenario: 多个 target 使用相同 name #### Scenario: 多个 target 使用相同 name
- **WHEN** 两个 target 配置不同 id 但相同 `name: "健康检查"` - **WHEN** 两个 target 配置不同 id 但相同 `name: "健康检查"`
- **THEN** 系统 SHALL 接受该配置不报错name 不要求全局唯一) - **THEN** 系统 SHALL 接受该配置不报错name 不要求全局唯一)
### Requirement: target description 字段
每个 target SHALL 支持可选的 `description` 字段作为目标说明。`description` SHALL 支持变量替换。`description` 长度 MUST 不超过 500 个字符,且允许为空字符串。`description` MUST NOT 参与 target 唯一性判定。
#### Scenario: 配置 description
- **WHEN** target 配置 `description: "检查生产 API 健康状态"`
- **THEN** 系统 SHALL 使用该值作为目标说明
#### Scenario: description 使用变量
- **WHEN** target 配置 `description: "${env} 环境健康检查"` 且 variables 中 `env: "生产"`
- **THEN** 系统 SHALL 将目标说明解析为 "生产 环境健康检查"
#### Scenario: description 缺省
- **WHEN** target 未配置 `description`
- **THEN** 系统 SHALL 接受该配置,且目标说明为 null
#### Scenario: description 为空字符串
- **WHEN** target 配置 `description: ""`
- **THEN** 系统 SHALL 接受该配置,且目标说明为空字符串
#### Scenario: description 超过最大长度报错
- **WHEN** target 配置超过 500 个字符的 `description`
- **THEN** 系统 SHALL 以配置错误退出,提示 description 长度不合法

View File

@@ -44,7 +44,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
#### Scenario: 名称列 #### Scenario: 名称列
- **WHEN** 表格渲染 - **WHEN** 表格渲染
- **THEN** 名称列 SHALL 显示目标名称,支持字母排序zh-CNellipsis 超长名称自动省略并 Tooltip 显示全名 - **THEN** 名称列 SHALL 显示目标名称ellipsis 超长名称自动省略并 Tooltip 显示全名,且 SHALL NOT 支持排序
#### Scenario: 类型列 #### Scenario: 类型列
- **WHEN** 表格渲染 - **WHEN** 表格渲染
@@ -68,7 +68,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car
#### Scenario: 延迟列 #### Scenario: 延迟列
- **WHEN** 表格渲染 - **WHEN** 表格渲染
- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ms" - **THEN** 延迟列标题 SHALL 展示为"延迟(ms)",单元格 SHALL 显示最近一次检查的延迟毫秒数值并右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+"
#### Scenario: 间隔列移除 #### Scenario: 间隔列移除
- **WHEN** 表格渲染 - **WHEN** 表格渲染

View File

@@ -109,6 +109,10 @@
"http" "http"
], ],
"properties": { "properties": {
"description": {
"maxLength": 500,
"type": "string"
},
"expect": { "expect": {
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
@@ -404,6 +408,7 @@
"type": "string" "type": "string"
}, },
"id": { "id": {
"maxLength": 30,
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
@@ -411,6 +416,7 @@
"type": "string" "type": "string"
}, },
"name": { "name": {
"maxLength": 30,
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
@@ -504,6 +510,10 @@
"cmd" "cmd"
], ],
"properties": { "properties": {
"description": {
"maxLength": 500,
"type": "string"
},
"expect": { "expect": {
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
@@ -640,6 +650,7 @@
"type": "string" "type": "string"
}, },
"id": { "id": {
"maxLength": 30,
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
@@ -647,6 +658,7 @@
"type": "string" "type": "string"
}, },
"name": { "name": {
"maxLength": 30,
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
@@ -707,6 +719,10 @@
"db" "db"
], ],
"properties": { "properties": {
"description": {
"maxLength": 500,
"type": "string"
},
"expect": { "expect": {
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
@@ -869,6 +885,7 @@
"type": "string" "type": "string"
}, },
"id": { "id": {
"maxLength": 30,
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
@@ -876,6 +893,7 @@
"type": "string" "type": "string"
}, },
"name": { "name": {
"maxLength": 30,
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },

View File

@@ -27,6 +27,7 @@ targets:
- id: "baidu-home" - id: "baidu-home"
name: "Baidu 首页可用" name: "Baidu 首页可用"
description: "监控百度首页的可用性和响应时间"
type: http type: http
group: "搜索引擎" group: "搜索引擎"
http: http:

View File

@@ -144,6 +144,7 @@ function resolveTarget(
result.intervalMs = intervalMs; result.intervalMs = intervalMs;
result.timeoutMs = timeoutMs; result.timeoutMs = timeoutMs;
result.description = target.description ?? null;
return result; return result;
} }

View File

@@ -187,6 +187,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
exec: t.cmd.exec, exec: t.cmd.exec,
maxOutputBytes, maxOutputBytes,
}, },
description: null,
expect: target.expect as CommandExpectConfig | undefined, expect: target.expect as CommandExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
id: t.id, id: t.id,

View File

@@ -180,6 +180,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
query: t.db.query, query: t.db.query,
url: t.db.url, url: t.db.url,
}, },
description: null,
expect: target.expect as DbExpectConfig | undefined, expect: target.expect as DbExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
id: t.id, id: t.id,

View File

@@ -112,6 +112,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB"); const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
return { return {
description: null,
expect: target.expect as HttpExpectConfig | undefined, expect: target.expect as HttpExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
http: { http: {

View File

@@ -49,11 +49,12 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
export function createTargetSchema(checker: CheckerDefinition): TSchema { export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = { const properties: Record<string, TSchema> = {
description: Type.Optional(Type.String({ maxLength: 500 })),
expect: Type.Optional(checker.schemas.expect), expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()), group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }), id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema), interval: Type.Optional(durationSchema),
name: Type.Optional(Type.String({ minLength: 1 })), name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })),
timeout: Type.Optional(durationSchema), timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type), type: Type.Literal(checker.type),
}; };
@@ -68,10 +69,11 @@ function cloneSchema(schema: TSchema): Record<string, unknown> {
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema { function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Object( return Type.Object(
{ {
description: Type.Optional(Type.String({ maxLength: 500 })),
group: Type.Optional(Type.String()), group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }), id: Type.String({ maxLength: 30, minLength: 1 }),
interval: Type.Optional(durationSchema), interval: Type.Optional(durationSchema),
name: Type.Optional(Type.String({ minLength: 1 })), name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })),
timeout: Type.Optional(durationSchema), timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]), type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
}, },

View File

@@ -10,6 +10,7 @@ const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets ( CREATE TABLE IF NOT EXISTS targets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL, type TEXT NOT NULL,
target TEXT NOT NULL, target TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}', config TEXT NOT NULL DEFAULT '{}',
@@ -316,10 +317,10 @@ export class ProbeStore {
const configIds = new Set(targets.map((t) => t.id)); const configIds = new Set(targets.map((t) => t.id));
const insertStmt = this.db.prepare( const insertStmt = this.db.prepare(
"INSERT INTO targets (id, name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO targets (id, name, description, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
); );
const updateStmt = this.db.prepare( const updateStmt = this.db.prepare(
"UPDATE targets SET name = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?", "UPDATE targets SET name = ?, description = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?",
); );
const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?"); const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
@@ -332,9 +333,9 @@ export class ProbeStore {
const expect = t.expect ? JSON.stringify(t.expect) : null; const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingIds.has(t.id)) { if (existingIds.has(t.id)) {
updateStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id); updateStmt.run(t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);
} else { } else {
insertStmt.run(t.id, t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group); insertStmt.run(t.id, t.name, t.description, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group);
} }
} }

View File

@@ -41,6 +41,7 @@ export interface ProbeConfig {
export interface RawTargetConfig { export interface RawTargetConfig {
[configKey: string]: unknown; [configKey: string]: unknown;
description?: string;
expect?: unknown; expect?: unknown;
group?: string; group?: string;
id: string; id: string;
@@ -52,6 +53,7 @@ export interface RawTargetConfig {
export interface ResolvedTargetBase { export interface ResolvedTargetBase {
[key: string]: unknown; [key: string]: unknown;
description: null | string;
expect?: unknown; expect?: unknown;
group: string; group: string;
id: string; id: string;
@@ -79,6 +81,7 @@ export interface StoredCheckResult {
export interface StoredTarget { export interface StoredTarget {
config: string; config: string;
description: null | string;
expect: null | string; expect: null | string;
grp: string; grp: string;
id: string; id: string;

View File

@@ -56,6 +56,7 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
return { return {
currentStreak, currentStreak,
description: target.description,
group: target.grp, group: target.grp,
id: target.id, id: target.id,
interval: formatDuration(target.interval_ms), interval: formatDuration(target.interval_ms),

View File

@@ -99,6 +99,7 @@ export interface TargetStats {
export interface TargetStatus { export interface TargetStatus {
currentStreak: CurrentStreak | null; currentStreak: CurrentStreak | null;
description: null | string;
group: string; group: string;
id: string; id: string;
interval: string; interval: string;

View File

@@ -38,6 +38,7 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
label: "最新检查时间", label: "最新检查时间",
}, },
{ content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" }, { content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" },
{ content: target.description ?? "", label: "描述", span: 2 },
]} ]}
/> />

View File

@@ -8,7 +8,7 @@ import { StatusBar } from "../components/StatusBar";
import { StatusDot } from "../components/StatusDot"; import { StatusDot } from "../components/StatusDot";
import { getAvailabilityProgressColor } from "./color-threshold"; import { getAvailabilityProgressColor } from "./color-threshold";
import { statusFilter } from "./target-table-filters"; import { statusFilter } from "./target-table-filters";
import { availabilitySorter, latencySorter, nameSorter } from "./target-table-sorters"; import { availabilitySorter, latencySorter } from "./target-table-sorters";
export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> { export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryTableCol<TargetStatus>> {
return [ return [
@@ -24,8 +24,6 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
{ {
colKey: "name", colKey: "name",
ellipsis: true, ellipsis: true,
sorter: nameSorter,
sortType: "all",
title: "名称", title: "名称",
}, },
{ {
@@ -88,13 +86,13 @@ export function createTargetTableColumns(checkerTypes: string[]): Array<PrimaryT
const ms = row.latestCheck?.durationMs; const ms = row.latestCheck?.durationMs;
if (ms === null || ms === undefined) return <span className="text-disabled">-</span>; if (ms === null || ms === undefined) return <span className="text-disabled">-</span>;
const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error"; const colorClass = ms <= 100 ? "latency-ok" : ms <= 500 ? "latency-warn" : "latency-error";
const latencyText = ms > 9999 ? "9999+ms" : `${Math.round(ms)}ms`; const latencyText = ms > 9999 ? "9999+" : `${Math.round(ms)}`;
return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>; return <span className={`${colorClass} latency-value tabular-nums`}>{latencyText}</span>;
}, },
colKey: "latestCheck.durationMs", colKey: "latestCheck.durationMs",
sorter: latencySorter, sorter: latencySorter,
sortType: "all", sortType: "all",
title: "延迟", title: "延迟(ms)",
width: 75, width: 75,
}, },
]; ];

View File

@@ -13,10 +13,6 @@ export function latencySorter(a: TargetStatus, b: TargetStatus): number {
return (a.latestCheck?.durationMs ?? Infinity) - (b.latestCheck?.durationMs ?? Infinity); return (a.latestCheck?.durationMs ?? Infinity) - (b.latestCheck?.durationMs ?? Infinity);
} }
export function nameSorter(a: TargetStatus, b: TargetStatus): number {
return a.name.localeCompare(b.name, "zh-CN");
}
export function statusSorter(a: TargetStatus, b: TargetStatus): number { export function statusSorter(a: TargetStatus, b: TargetStatus): number {
return getStatusRank(a) - getStatusRank(b); return getStatusRank(a) - getStatusRank(b);
} }

View File

@@ -41,6 +41,7 @@ describe("API 路由", () => {
store = new ProbeStore(join(tempDir, "test.db")); store = new ProbeStore(join(tempDir, "test.db"));
store.syncTargets([ store.syncTargets([
{ {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},
@@ -64,6 +65,7 @@ describe("API 路由", () => {
exec: "echo", exec: "echo",
maxOutputBytes: 104857600, maxOutputBytes: 104857600,
}, },
description: null,
group: "default", group: "default",
id: "test-b", id: "test-b",
intervalMs: 60000, intervalMs: 60000,

View File

@@ -11,6 +11,7 @@ import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstra
type ShutdownSignal = "SIGINT" | "SIGTERM"; type ShutdownSignal = "SIGINT" | "SIGTERM";
const target: ResolvedTargetBase = { const target: ResolvedTargetBase = {
description: null,
group: "default", group: "default",
id: "test", id: "test",
intervalMs: 30000, intervalMs: 30000,

View File

@@ -1534,4 +1534,150 @@ targets:
"无效的时长格式", "无效的时长格式",
); );
}); });
test("解析 description 字段", async () => {
const configPath = join(tempDir, "description.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: "检查生产 API 健康状态"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBe("检查生产 API 健康状态");
});
test("description 使用变量替换", async () => {
const configPath = join(tempDir, "description-var.yaml");
await writeFile(
configPath,
`variables:
env: "生产"
targets:
- id: "api-health"
description: "\${env} 环境健康检查"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBe("生产 环境健康检查");
});
test("description 缺省为 null", async () => {
const configPath = join(tempDir, "no-description.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBeNull();
});
test("description 为空字符串通过", async () => {
const configPath = join(tempDir, "empty-description.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: ""
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.description).toBe("");
});
test("description 非字符串抛出错误", async () => {
const configPath = join(tempDir, "bad-description-type.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: 123
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("description");
});
test("description 超过 500 字符抛出错误", async () => {
const configPath = join(tempDir, "long-description.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
description: "${"a".repeat(501)}"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("description");
});
test("变量替换后 description 超长抛出错误", async () => {
const configPath = join(tempDir, "var-long-description.yaml");
await writeFile(
configPath,
`variables:
prefix: "${"x".repeat(490)}"
targets:
- id: "api-health"
description: "\${prefix}${"a".repeat(15)}"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("description");
});
test("id 超过 30 字符抛出错误", async () => {
const configPath = join(tempDir, "long-id.yaml");
await writeFile(
configPath,
`targets:
- id: "${"a".repeat(31)}"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("id");
});
test("name 超过 30 字符抛出错误", async () => {
const configPath = join(tempDir, "long-name.yaml");
await writeFile(
configPath,
`targets:
- id: "test"
name: "${"a".repeat(31)}"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("name");
});
}); });

View File

@@ -55,6 +55,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
exec: "bun", exec: "bun",
maxOutputBytes: 1024 * 1024, maxOutputBytes: 1024 * 1024,
}, },
description: null,
group: "default", group: "default",
id: name, id: name,
intervalMs: 60000, intervalMs: 60000,
@@ -259,6 +260,7 @@ describe("ProbeEngine", () => {
try { try {
const httpTarget: ResolvedHttpTarget = { const httpTarget: ResolvedHttpTarget = {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},

View File

@@ -30,6 +30,7 @@ function makeTarget(
maxOutputBytes: 1024 * 1024, maxOutputBytes: 1024 * 1024,
...cmd, ...cmd,
}, },
description: null,
group: "default", group: "default",
id: "test-cmd", id: "test-cmd",
intervalMs: 60000, intervalMs: 60000,

View File

@@ -19,6 +19,7 @@ function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<Res
url: "sqlite://:memory:", url: "sqlite://:memory:",
...db, ...db,
}, },
description: null,
group: "default", group: "default",
id: "test-db", id: "test-db",
intervalMs: 60000, intervalMs: 60000,

View File

@@ -155,6 +155,7 @@ describe("HttpChecker", () => {
url?: string; url?: string;
}): ResolvedHttpTarget { }): ResolvedHttpTarget {
return { return {
description: null,
expect: overrides.expect, expect: overrides.expect,
group: "default", group: "default",
http: { http: {

View File

@@ -29,6 +29,7 @@ function targetId(store: ProbeStore, name: string): string {
} }
const httpTarget: ResolvedHttpTarget = { const httpTarget: ResolvedHttpTarget = {
description: null,
expect: { maxDurationMs: 3000, status: [200] }, expect: { maxDurationMs: 3000, status: [200] },
group: "default", group: "default",
http: { http: {
@@ -54,6 +55,7 @@ const commandTarget: ResolvedCommandTarget = {
exec: "ping", exec: "ping",
maxOutputBytes: 104857600, maxOutputBytes: 104857600,
}, },
description: null,
group: "default", group: "default",
id: "test-cmd", id: "test-cmd",
intervalMs: 60000, intervalMs: 60000,
@@ -364,6 +366,7 @@ describe("ProbeStore", () => {
test("删除 target 级联删除 check_results", () => { test("删除 target 级联删除 check_results", () => {
const cascadeStore = new ProbeStore(join(tempDir, "cascade.db")); const cascadeStore = new ProbeStore(join(tempDir, "cascade.db"));
const cascadeTarget: ResolvedHttpTarget = { const cascadeTarget: ResolvedHttpTarget = {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},
@@ -427,6 +430,7 @@ describe("ProbeStore", () => {
const freshStore = new ProbeStore(join(tempDir, "fresh-map.db")); const freshStore = new ProbeStore(join(tempDir, "fresh-map.db"));
freshStore.syncTargets([ freshStore.syncTargets([
{ {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},
@@ -474,6 +478,7 @@ describe("ProbeStore", () => {
const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db")); const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db"));
freshStore.syncTargets([ freshStore.syncTargets([
{ {
description: null,
group: "default", group: "default",
http: { http: {
headers: {}, headers: {},
@@ -662,4 +667,35 @@ describe("ProbeStore", () => {
pruneStore.close(); pruneStore.close();
}); });
test("syncTargets 持久化 description", () => {
const descStore = new ProbeStore(join(tempDir, "desc.db"));
const targetWithDesc: ResolvedHttpTarget = {
...httpTarget,
description: "检查 API 健康状态",
id: "desc-test",
name: "desc-test",
};
descStore.syncTargets([targetWithDesc]);
const t = descStore.getTargets()[0]!;
expect(t.description).toBe("检查 API 健康状态");
descStore.close();
});
test("未配置 description 时持久化为 null", () => {
const noDescStore = new ProbeStore(join(tempDir, "no-desc.db"));
noDescStore.syncTargets([{ ...httpTarget, description: null, id: "no-desc", name: "no-desc" }]);
const t = noDescStore.getTargets()[0]!;
expect(t.description).toBeNull();
noDescStore.close();
});
test("同步更新 description", () => {
const updateDescStore = new ProbeStore(join(tempDir, "update-desc.db"));
updateDescStore.syncTargets([{ ...httpTarget, description: "旧描述", id: "update-desc", name: "update-desc" }]);
updateDescStore.syncTargets([{ ...httpTarget, description: "新描述", id: "update-desc", name: "update-desc" }]);
const t = updateDescStore.getTargets()[0]!;
expect(t.description).toBe("新描述");
updateDescStore.close();
});
}); });

View File

@@ -9,6 +9,7 @@ import { OverviewTab } from "../../../src/web/components/OverviewTab";
describe("OverviewTab", () => { describe("OverviewTab", () => {
const target: TargetStatus = { const target: TargetStatus = {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "30s", interval: "30s",

View File

@@ -19,6 +19,7 @@ describe("TargetBoard", () => {
const targets: TargetStatus[] = [ const targets: TargetStatus[] = [
{ {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "30s", interval: "30s",
@@ -31,6 +32,7 @@ describe("TargetBoard", () => {
}, },
{ {
currentStreak: null, currentStreak: null,
description: null,
group: "production", group: "production",
id: "2", id: "2",
interval: "30s", interval: "30s",

View File

@@ -9,6 +9,7 @@ import { TargetDetailDrawer } from "../../../src/web/components/TargetDetailDraw
describe("TargetDetailDrawer", () => { describe("TargetDetailDrawer", () => {
const target: TargetStatus = { const target: TargetStatus = {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "30s", interval: "30s",

View File

@@ -15,6 +15,7 @@ describe("TargetGroup", () => {
const targets: TargetStatus[] = [ const targets: TargetStatus[] = [
{ {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "30s", interval: "30s",
@@ -33,6 +34,7 @@ describe("TargetGroup", () => {
}, },
{ {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "2", id: "2",
interval: "30s", interval: "30s",

View File

@@ -20,6 +20,7 @@ function getColumn(columns: Array<PrimaryTableCol<TargetStatus>>, colKey: string
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus { function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return { return {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "5s", interval: "5s",
@@ -119,7 +120,40 @@ describe("createTargetTableColumns", () => {
rowIndex: 0, rowIndex: 0,
}); });
expect(element.props.children).toBe("9999+ms"); expect(element.props.children).toBe("9999+");
expect(element.props.className).toContain("latency-value"); expect(element.props.className).toContain("latency-value");
}); });
test("延迟列标题为 延迟(ms)", () => {
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
expect(latencyColumn.title).toBe("延迟(ms)");
});
test("延迟列正常值不包含 ms 单位", () => {
const latencyColumn = getColumn(createTargetTableColumns(["http"]), "latestCheck.durationMs");
const renderCell = latencyColumn.cell as (params: PrimaryTableCellParams<TargetStatus>) => {
props: { children: string; className: string };
};
const element = renderCell({
col: latencyColumn,
colIndex: 6,
row: makeTarget({
latestCheck: {
durationMs: 123,
failure: null,
matched: true,
statusDetail: "200",
timestamp: "2026-01-01T00:00:00.000Z",
},
}),
rowIndex: 0,
});
expect(element.props.children).toBe("123");
});
test("名称列无排序配置", () => {
const nameColumn = getColumn(createTargetTableColumns(["http"]), "name");
expect(nameColumn.sorter).toBeUndefined();
expect(nameColumn.sortType).toBeUndefined();
});
}); });

View File

@@ -2,16 +2,12 @@ import { describe, expect, test } from "bun:test";
import type { TargetStatus } from "../../../src/shared/api"; import type { TargetStatus } from "../../../src/shared/api";
import { import { availabilitySorter, latencySorter, statusSorter } from "../../../src/web/constants/target-table-sorters";
availabilitySorter,
latencySorter,
nameSorter,
statusSorter,
} from "../../../src/web/constants/target-table-sorters";
function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus { function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return { return {
currentStreak: null, currentStreak: null,
description: null,
group: "default", group: "default",
id: "1", id: "1",
interval: "5s", interval: "5s",
@@ -97,18 +93,3 @@ describe("latencySorter", () => {
expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0); expect(latencySorter(noLatency, hasLatency)).toBeGreaterThan(0);
}); });
}); });
describe("nameSorter", () => {
test("按名称字母排序", () => {
const a = makeTarget({ name: "Alpha" });
const b = makeTarget({ name: "Beta" });
expect(nameSorter(a, b)).toBeLessThan(0);
});
test("中文名称排序", () => {
const a = makeTarget({ name: "百度" });
const b = makeTarget({ name: "谷歌" });
const result = nameSorter(a, b);
expect(typeof result).toBe("number");
});
});