diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d351fdb..e7f2826 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -463,7 +463,7 @@ TcpChecker implements Checker **Schema**: -- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(展示名称)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp +- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(展示名称)、description(描述)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp - `check_results` 表:target_id(TEXT FK CASCADE,引用配置 target id)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON) - 复合索引:`(target_id, timestamp)` diff --git a/README.md b/README.md index fb62703..5c42157 100644 --- a/README.md +++ b/README.md @@ -176,14 +176,15 @@ targets: 每个 target 的通用字段: -| 字段 | 说明 | 必填 | -| ---------- | ---------------------------------------------------------- | -------------------- | -| `id` | 目标唯一标识,支持字母数字、下划线、连字符,不参与变量替换 | 是 | -| `name` | 展示名称,支持变量替换;省略时使用 `id` | 否 | -| `type` | 目标类型:`http`、`cmd`、`db` | 是 | -| `group` | 分组名称 | 否,默认 `"default"` | -| `interval` | 覆盖全局拨测间隔 | 否 | -| `timeout` | 覆盖全局超时时间 | 否 | +| 字段 | 说明 | 必填 | +| ------------- | ------------------------------------------------------------------------ | -------------------- | +| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | +| `name` | 展示名称,最长 30 字符,支持变量替换;省略时使用 `id` | 否 | +| `description` | 目标描述,最长 500 字符,支持变量替换,允许空字符串 | 否 | +| `type` | 目标类型:`http`、`cmd`、`db` | 是 | +| `group` | 分组名称 | 否,默认 `"default"` | +| `interval` | 覆盖全局拨测间隔 | 否 | +| `timeout` | 覆盖全局超时时间 | 否 | **HTTP 类型** (`type: http`) diff --git a/openspec/specs/probe-api/spec.md b/openspec/specs/probe-api/spec.md index a8d3012..d7d3173 100644 --- a/openspec/specs/probe-api/spec.md +++ b/openspec/specs/probe-api/spec.md @@ -17,7 +17,11 @@ #### Scenario: targets 字段 - **WHEN** Dashboard 响应包含 targets -- **THEN** targets 数组中每个元素 SHALL 包含目标基本信息(id、name、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 参数缺失 - **WHEN** 客户端请求 `GET /api/dashboard` 未提供 window 参数 @@ -105,7 +109,7 @@ Dashboard API SHALL 返回基于时间窗口计算的目标统计和连续状态 #### Scenario: TargetStatus 类型 - **WHEN** 前后端共享 `TargetStatus` 类型 -- **THEN** 该类型 SHALL 包含 stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段 +- **THEN** 该类型 SHALL 包含目标基本信息字段(id、name、description、group、type、target、interval)、stats(totalChecks、upChecks、downChecks、availability)、currentStreak 和 recentSamples 字段,其中 description 类型为 null 或字符串 #### Scenario: TargetMetricsResponse 类型 - **WHEN** 前后端共享 `TargetMetricsResponse` 类型 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 736b4f6..83260bf 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -328,3 +328,45 @@ #### Scenario: dataDir 使用默认值 - **WHEN** 未配置 `server.dataDir`(使用默认值 `./data`) - **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 diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index 33d04fc..5fd6c55 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/probe-data-store/spec.md @@ -26,15 +26,19 @@ - **THEN** check_results 表的外键约束 SHALL 使用 `ON DELETE CASCADE`,确保删除目标时自动清理关联结果记录 ### Requirement: targets 表同步 -系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置和分组信息。 +系统 SHALL 在启动时将 YAML 配置中的目标列表同步到 SQLite targets 表,并持久化 target 类型、展示摘要、领域配置、调度配置、expect 配置、分组信息和目标说明。 #### Scenario: 首次同步目标 - **WHEN** 数据库为空且 YAML 中定义了 N 个 typed target -- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、type、target、config、interval_ms、timeout_ms、expect 和 grp +- **THEN** 系统 SHALL 将所有目标插入 targets 表,包含 name、description、type、target、config、interval_ms、timeout_ms、expect 和 grp #### Scenario: 配置变更后重新同步 - **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 表追加写入 系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。 diff --git a/openspec/specs/target-detail-drawer/spec.md b/openspec/specs/target-detail-drawer/spec.md index 457b17a..3a8a9d9 100644 --- a/openspec/specs/target-detail-drawer/spec.md +++ b/openspec/specs/target-detail-drawer/spec.md @@ -215,7 +215,11 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs #### Scenario: 基本信息内容 - **WHEN** 概览面板渲染 -- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情 +- **THEN** Descriptions SHALL 展示:目标地址、检查间隔、最新检查时间、状态详情、描述,其中描述 SHALL 位于最后一行 + +#### Scenario: 描述行占满整行 +- **WHEN** 概览面板渲染基本信息 +- **THEN** 描述项 SHALL 占据 Descriptions 的一整行,内容 SHALL 使用 `target.description ?? ""`,即使 description 为空也 SHALL 渲染该项 #### Scenario: 统计区上下布局卡片 - **WHEN** 概览面板渲染且有统计数据 diff --git a/openspec/specs/target-identity/spec.md b/openspec/specs/target-identity/spec.md index f55a0d3..07f131e 100644 --- a/openspec/specs/target-identity/spec.md +++ b/openspec/specs/target-identity/spec.md @@ -5,7 +5,7 @@ ## Requirements ### 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 - **WHEN** target 配置 `id: "api-health"` @@ -23,6 +23,10 @@ - **WHEN** target 配置 `id: ""` - **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空 +#### Scenario: id 超过最大长度报错 +- **WHEN** target 配置超过 30 个字符的 `id` +- **THEN** 系统 SHALL 以配置错误退出,提示 id 长度不合法 + #### Scenario: id 不合法报错 - **WHEN** target 配置 `id: "_invalid"` 或 `id: "-start"` 或 `id: "has space"` - **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则 @@ -32,7 +36,7 @@ - **THEN** 系统 SHALL 以配置错误退出,提示 id 重复 ### 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 - **WHEN** target 配置 `id: "api-health"` 和 `name: "API 健康检查"` @@ -46,6 +50,37 @@ - **WHEN** target 配置 `id: "api-health"` 但未配置 `name` - **THEN** 系统 SHALL 使用 "api-health" 作为展示名称 +#### Scenario: name 为空字符串报错 +- **WHEN** target 配置 `name: ""` +- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法 + +#### Scenario: name 超过最大长度报错 +- **WHEN** target 配置超过 30 个字符的 `name` +- **THEN** 系统 SHALL 以配置错误退出,提示 name 长度不合法 + #### Scenario: 多个 target 使用相同 name - **WHEN** 两个 target 配置不同 id 但相同 `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 长度不合法 diff --git a/openspec/specs/target-table/spec.md b/openspec/specs/target-table/spec.md index 2cfcec3..51a69e0 100644 --- a/openspec/specs/target-table/spec.md +++ b/openspec/specs/target-table/spec.md @@ -44,7 +44,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car #### Scenario: 名称列 - **WHEN** 表格渲染 -- **THEN** 名称列 SHALL 显示目标名称,支持字母排序(zh-CN),ellipsis 超长名称自动省略并 Tooltip 显示全名 +- **THEN** 名称列 SHALL 显示目标名称,ellipsis 超长名称自动省略并 Tooltip 显示全名,且 SHALL NOT 支持排序 #### Scenario: 类型列 - **WHEN** 表格渲染 @@ -68,7 +68,7 @@ Dashboard SHALL 按 group 字段将目标分组,每个分组使用 TDesign Car #### Scenario: 延迟列 - **WHEN** 表格渲染 -- **THEN** 延迟列 SHALL 显示最近一次检查的延迟毫秒数,右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+ms" +- **THEN** 延迟列标题 SHALL 展示为"延迟(ms)",单元格 SHALL 显示最近一次检查的延迟毫秒数值并右对齐,颜色通过 CSS 类控制;超过 9999ms 时 SHALL 显示为"9999+" #### Scenario: 间隔列移除 - **WHEN** 表格渲染 diff --git a/probe-config.schema.json b/probe-config.schema.json index 98fe1bd..24b02c4 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -109,6 +109,10 @@ "http" ], "properties": { + "description": { + "maxLength": 500, + "type": "string" + }, "expect": { "additionalProperties": false, "type": "object", @@ -404,6 +408,7 @@ "type": "string" }, "id": { + "maxLength": 30, "minLength": 1, "type": "string" }, @@ -411,6 +416,7 @@ "type": "string" }, "name": { + "maxLength": 30, "minLength": 1, "type": "string" }, @@ -504,6 +510,10 @@ "cmd" ], "properties": { + "description": { + "maxLength": 500, + "type": "string" + }, "expect": { "additionalProperties": false, "type": "object", @@ -640,6 +650,7 @@ "type": "string" }, "id": { + "maxLength": 30, "minLength": 1, "type": "string" }, @@ -647,6 +658,7 @@ "type": "string" }, "name": { + "maxLength": 30, "minLength": 1, "type": "string" }, @@ -707,6 +719,10 @@ "db" ], "properties": { + "description": { + "maxLength": 500, + "type": "string" + }, "expect": { "additionalProperties": false, "type": "object", @@ -869,6 +885,7 @@ "type": "string" }, "id": { + "maxLength": 30, "minLength": 1, "type": "string" }, @@ -876,6 +893,7 @@ "type": "string" }, "name": { + "maxLength": 30, "minLength": 1, "type": "string" }, diff --git a/probes.example.yaml b/probes.example.yaml index 180404a..cab4f0f 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -27,6 +27,7 @@ targets: - id: "baidu-home" name: "Baidu 首页可用" + description: "监控百度首页的可用性和响应时间" type: http group: "搜索引擎" http: diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index c05181f..d5190a5 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -144,6 +144,7 @@ function resolveTarget( result.intervalMs = intervalMs; result.timeoutMs = timeoutMs; + result.description = target.description ?? null; return result; } diff --git a/src/server/checker/runner/cmd/execute.ts b/src/server/checker/runner/cmd/execute.ts index 21a335a..f9d342a 100644 --- a/src/server/checker/runner/cmd/execute.ts +++ b/src/server/checker/runner/cmd/execute.ts @@ -187,6 +187,7 @@ export class CommandChecker implements CheckerDefinition exec: t.cmd.exec, maxOutputBytes, }, + description: null, expect: target.expect as CommandExpectConfig | undefined, group: target.group ?? "default", id: t.id, diff --git a/src/server/checker/runner/db/execute.ts b/src/server/checker/runner/db/execute.ts index 848b6ca..6ffccc5 100644 --- a/src/server/checker/runner/db/execute.ts +++ b/src/server/checker/runner/db/execute.ts @@ -180,6 +180,7 @@ export class DbChecker implements CheckerDefinition { query: t.db.query, url: t.db.url, }, + description: null, expect: target.expect as DbExpectConfig | undefined, group: target.group ?? "default", id: t.id, diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index f407881..ee278da 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -112,6 +112,7 @@ export class HttpChecker implements CheckerDefinition { const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB"); return { + description: null, expect: target.expect as HttpExpectConfig | undefined, group: target.group ?? "default", http: { diff --git a/src/server/checker/schema/builder.ts b/src/server/checker/schema/builder.ts index 537eacf..241acab 100644 --- a/src/server/checker/schema/builder.ts +++ b/src/server/checker/schema/builder.ts @@ -49,11 +49,12 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external export function createTargetSchema(checker: CheckerDefinition): TSchema { const properties: Record = { + description: Type.Optional(Type.String({ maxLength: 500 })), expect: Type.Optional(checker.schemas.expect), group: Type.Optional(Type.String()), - id: Type.String({ minLength: 1 }), + id: Type.String({ maxLength: 30, minLength: 1 }), interval: Type.Optional(durationSchema), - name: Type.Optional(Type.String({ minLength: 1 })), + name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })), timeout: Type.Optional(durationSchema), type: Type.Literal(checker.type), }; @@ -68,10 +69,11 @@ function cloneSchema(schema: TSchema): Record { function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema { return Type.Object( { + description: Type.Optional(Type.String({ maxLength: 500 })), group: Type.Optional(Type.String()), - id: Type.String({ minLength: 1 }), + id: Type.String({ maxLength: 30, minLength: 1 }), interval: Type.Optional(durationSchema), - name: Type.Optional(Type.String({ minLength: 1 })), + name: Type.Optional(Type.String({ maxLength: 30, minLength: 1 })), timeout: Type.Optional(durationSchema), type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]), }, diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 999c43f..458fc5a 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -10,6 +10,7 @@ const CREATE_TARGETS_TABLE = ` CREATE TABLE IF NOT EXISTS targets ( id TEXT PRIMARY KEY, name TEXT NOT NULL, + description TEXT, type TEXT NOT NULL, target TEXT NOT NULL, config TEXT NOT NULL DEFAULT '{}', @@ -316,10 +317,10 @@ export class ProbeStore { const configIds = new Set(targets.map((t) => t.id)); 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( - "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 = ?"); @@ -332,9 +333,9 @@ export class ProbeStore { const expect = t.expect ? JSON.stringify(t.expect) : null; 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 { - 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); } } diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index 58ab271..459eb82 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -41,6 +41,7 @@ export interface ProbeConfig { export interface RawTargetConfig { [configKey: string]: unknown; + description?: string; expect?: unknown; group?: string; id: string; @@ -52,6 +53,7 @@ export interface RawTargetConfig { export interface ResolvedTargetBase { [key: string]: unknown; + description: null | string; expect?: unknown; group: string; id: string; @@ -79,6 +81,7 @@ export interface StoredCheckResult { export interface StoredTarget { config: string; + description: null | string; expect: null | string; grp: string; id: string; diff --git a/src/server/routes/dashboard.ts b/src/server/routes/dashboard.ts index 9483c0b..04633a7 100644 --- a/src/server/routes/dashboard.ts +++ b/src/server/routes/dashboard.ts @@ -56,6 +56,7 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode): return { currentStreak, + description: target.description, group: target.grp, id: target.id, interval: formatDuration(target.interval_ms), diff --git a/src/shared/api.ts b/src/shared/api.ts index 898b18a..bcd0da1 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -99,6 +99,7 @@ export interface TargetStats { export interface TargetStatus { currentStreak: CurrentStreak | null; + description: null | string; group: string; id: string; interval: string; diff --git a/src/web/components/OverviewTab.tsx b/src/web/components/OverviewTab.tsx index 9c59d1f..9862cc3 100644 --- a/src/web/components/OverviewTab.tsx +++ b/src/web/components/OverviewTab.tsx @@ -38,6 +38,7 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab label: "最新检查时间", }, { content: target.latestCheck?.statusDetail ?? "-", label: "状态详情" }, + { content: target.description ?? "", label: "描述", span: 2 }, ]} /> diff --git a/src/web/constants/target-table-columns.tsx b/src/web/constants/target-table-columns.tsx index 4364c90..4e4c6eb 100644 --- a/src/web/constants/target-table-columns.tsx +++ b/src/web/constants/target-table-columns.tsx @@ -8,7 +8,7 @@ import { StatusBar } from "../components/StatusBar"; import { StatusDot } from "../components/StatusDot"; import { getAvailabilityProgressColor } from "./color-threshold"; 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> { return [ @@ -24,8 +24,6 @@ export function createTargetTableColumns(checkerTypes: string[]): Array-; 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 {latencyText}; }, colKey: "latestCheck.durationMs", sorter: latencySorter, sortType: "all", - title: "延迟", + title: "延迟(ms)", width: 75, }, ]; diff --git a/src/web/constants/target-table-sorters.ts b/src/web/constants/target-table-sorters.ts index 8c24a09..b7d89d2 100644 --- a/src/web/constants/target-table-sorters.ts +++ b/src/web/constants/target-table-sorters.ts @@ -13,10 +13,6 @@ export function latencySorter(a: TargetStatus, b: TargetStatus): number { 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 { return getStatusRank(a) - getStatusRank(b); } diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 50ae5e5..3296127 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -41,6 +41,7 @@ describe("API 路由", () => { store = new ProbeStore(join(tempDir, "test.db")); store.syncTargets([ { + description: null, group: "default", http: { headers: {}, @@ -64,6 +65,7 @@ describe("API 路由", () => { exec: "echo", maxOutputBytes: 104857600, }, + description: null, group: "default", id: "test-b", intervalMs: 60000, diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts index d53b8f5..905240d 100644 --- a/tests/server/bootstrap.test.ts +++ b/tests/server/bootstrap.test.ts @@ -11,6 +11,7 @@ import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstra type ShutdownSignal = "SIGINT" | "SIGTERM"; const target: ResolvedTargetBase = { + description: null, group: "default", id: "test", intervalMs: 30000, diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index 7e63b38..c855002 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -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"); + }); }); diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index c05076e..9853b56 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -55,6 +55,7 @@ function makeCommandTarget(name: string, overrides?: Partial { try { const httpTarget: ResolvedHttpTarget = { + description: null, group: "default", http: { headers: {}, diff --git a/tests/server/checker/runner/cmd/runner.test.ts b/tests/server/checker/runner/cmd/runner.test.ts index 8af4d07..d462c10 100644 --- a/tests/server/checker/runner/cmd/runner.test.ts +++ b/tests/server/checker/runner/cmd/runner.test.ts @@ -30,6 +30,7 @@ function makeTarget( maxOutputBytes: 1024 * 1024, ...cmd, }, + description: null, group: "default", id: "test-cmd", intervalMs: 60000, diff --git a/tests/server/checker/runner/db/execute.test.ts b/tests/server/checker/runner/db/execute.test.ts index 9f32d28..281481b 100644 --- a/tests/server/checker/runner/db/execute.test.ts +++ b/tests/server/checker/runner/db/execute.test.ts @@ -19,6 +19,7 @@ function makeTarget(db: Partial, overrides?: Partial { url?: string; }): ResolvedHttpTarget { return { + description: null, expect: overrides.expect, group: "default", http: { diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index b181526..2c4a1aa 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -29,6 +29,7 @@ function targetId(store: ProbeStore, name: string): string { } const httpTarget: ResolvedHttpTarget = { + description: null, expect: { maxDurationMs: 3000, status: [200] }, group: "default", http: { @@ -54,6 +55,7 @@ const commandTarget: ResolvedCommandTarget = { exec: "ping", maxOutputBytes: 104857600, }, + description: null, group: "default", id: "test-cmd", intervalMs: 60000, @@ -364,6 +366,7 @@ describe("ProbeStore", () => { test("删除 target 级联删除 check_results", () => { const cascadeStore = new ProbeStore(join(tempDir, "cascade.db")); const cascadeTarget: ResolvedHttpTarget = { + description: null, group: "default", http: { headers: {}, @@ -427,6 +430,7 @@ describe("ProbeStore", () => { const freshStore = new ProbeStore(join(tempDir, "fresh-map.db")); freshStore.syncTargets([ { + description: null, group: "default", http: { headers: {}, @@ -474,6 +478,7 @@ describe("ProbeStore", () => { const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db")); freshStore.syncTargets([ { + description: null, group: "default", http: { headers: {}, @@ -662,4 +667,35 @@ describe("ProbeStore", () => { 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(); + }); }); diff --git a/tests/web/components/OverviewTab.test.tsx b/tests/web/components/OverviewTab.test.tsx index a96c729..ad2fba3 100644 --- a/tests/web/components/OverviewTab.test.tsx +++ b/tests/web/components/OverviewTab.test.tsx @@ -9,6 +9,7 @@ import { OverviewTab } from "../../../src/web/components/OverviewTab"; describe("OverviewTab", () => { const target: TargetStatus = { currentStreak: null, + description: null, group: "default", id: "1", interval: "30s", diff --git a/tests/web/components/TargetBoard.test.tsx b/tests/web/components/TargetBoard.test.tsx index 88264cb..016f268 100644 --- a/tests/web/components/TargetBoard.test.tsx +++ b/tests/web/components/TargetBoard.test.tsx @@ -19,6 +19,7 @@ describe("TargetBoard", () => { const targets: TargetStatus[] = [ { currentStreak: null, + description: null, group: "default", id: "1", interval: "30s", @@ -31,6 +32,7 @@ describe("TargetBoard", () => { }, { currentStreak: null, + description: null, group: "production", id: "2", interval: "30s", diff --git a/tests/web/components/TargetDetailDrawer.test.tsx b/tests/web/components/TargetDetailDrawer.test.tsx index b56f423..92fe065 100644 --- a/tests/web/components/TargetDetailDrawer.test.tsx +++ b/tests/web/components/TargetDetailDrawer.test.tsx @@ -9,6 +9,7 @@ import { TargetDetailDrawer } from "../../../src/web/components/TargetDetailDraw describe("TargetDetailDrawer", () => { const target: TargetStatus = { currentStreak: null, + description: null, group: "default", id: "1", interval: "30s", diff --git a/tests/web/components/TargetGroup.test.tsx b/tests/web/components/TargetGroup.test.tsx index 7943e28..877bbdc 100644 --- a/tests/web/components/TargetGroup.test.tsx +++ b/tests/web/components/TargetGroup.test.tsx @@ -15,6 +15,7 @@ describe("TargetGroup", () => { const targets: TargetStatus[] = [ { currentStreak: null, + description: null, group: "default", id: "1", interval: "30s", @@ -33,6 +34,7 @@ describe("TargetGroup", () => { }, { currentStreak: null, + description: null, group: "default", id: "2", interval: "30s", diff --git a/tests/web/constants/target-table-columns.test.ts b/tests/web/constants/target-table-columns.test.ts index 5f2528c..f61cf4b 100644 --- a/tests/web/constants/target-table-columns.test.ts +++ b/tests/web/constants/target-table-columns.test.ts @@ -20,6 +20,7 @@ function getColumn(columns: Array>, colKey: string function makeTarget(overrides: Partial = {}): TargetStatus { return { currentStreak: null, + description: null, group: "default", id: "1", interval: "5s", @@ -119,7 +120,40 @@ describe("createTargetTableColumns", () => { rowIndex: 0, }); - expect(element.props.children).toBe("9999+ms"); + expect(element.props.children).toBe("9999+"); 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) => { + 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(); + }); }); diff --git a/tests/web/constants/target-table-sorters.test.ts b/tests/web/constants/target-table-sorters.test.ts index 3fe7d19..dac66be 100644 --- a/tests/web/constants/target-table-sorters.test.ts +++ b/tests/web/constants/target-table-sorters.test.ts @@ -2,16 +2,12 @@ import { describe, expect, test } from "bun:test"; import type { TargetStatus } from "../../../src/shared/api"; -import { - availabilitySorter, - latencySorter, - nameSorter, - statusSorter, -} from "../../../src/web/constants/target-table-sorters"; +import { availabilitySorter, latencySorter, statusSorter } from "../../../src/web/constants/target-table-sorters"; function makeTarget(overrides: Partial = {}): TargetStatus { return { currentStreak: null, + description: null, group: "default", id: "1", interval: "5s", @@ -97,18 +93,3 @@ describe("latencySorter", () => { 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"); - }); -});