diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7236449..d351fdb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -36,6 +36,7 @@ src/ checker/ types.ts 基础类型定义(ResolvedTargetBase、RawTargetConfig、DefaultsConfig、CheckResult 等基础 interface) config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析(输出 ResolvedConfig) + variables.ts 配置 variables 提取、target 字符串变量替换和 unresolved-variable issue 生成 schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口 builder.ts 全量 JSON Schema 组装(遍历 registry 生成) fragments.ts 共享 TypeBox schema 片段(duration、size、operator 等) @@ -99,7 +100,8 @@ probe-config.schema.json 用户配置 JSON Schema 导出物(用于 IDE 自动 启动流程: dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath) → bootstrap({ configPath, mode }) - → loadConfig(yaml) → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets } + → loadConfig(yaml:YAML 解析 → 变量替换 → 契约校验 → 语义校验 → resolve) + → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets } → ProbeStore(db) → store.syncTargets(targets) → ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start() → startServer({ config, mode, store }) @@ -192,7 +194,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode: - **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用 - 前端不得 `import src/server/` 下的任何文件 - **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string` -- **后端内部扩展**:`checker/types.ts` 中 `CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段 +- **后端内部扩展**:`checker/types.ts` 中 `CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetId` 等内部字段 - 存储层类型(`StoredTarget`、`StoredCheckResult`)独立定义,与 API 类型分离 - **Checker 类型分层**: - `checker/types.ts` 定义 base interface(`ResolvedTargetBase`、`RawTargetConfig`、`DefaultsConfig`),使用 index signature 支持扩展 @@ -204,7 +206,9 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode: ### 1.6 配置契约与校验 -配置加载流程固定为:`unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`。 +配置加载流程固定为:`unknown -> 变量替换 -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig`。 + +变量替换阶段由 `variables.ts` 负责,在 YAML 解析之后、AJV 契约校验之前执行。顶层 `variables` 支持 string/number/boolean 字面量,target 字符串字段支持 `${key}`、`${key|default}` 和 `$${key}`,解析优先级为 `variables -> process.env -> 默认值`;替换范围仅限 `targets`,且跳过 `id` 和 `type` 字段。 `config-loader.ts` 只负责 YAML 解析、契约调度、公共语义校验和最终运行期解析;checker 专属规则必须下沉到对应 checker 的 `schema.ts` 和 `validate.ts`。 @@ -222,7 +226,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode: 契约层使用 `src/server/checker/schema/` 中的 TypeBox fragments 生成 JSON Schema,并用 Ajv 执行启动期校验。Ajv 必须保持严格拒绝模式:`allErrors: true`、不启用类型强制转换、不注入默认值、不自动删除未知字段。 -默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `http.headers`、`defaults.http.headers`、`expect.headers`、`cmd.env`。 +默认对象策略是 `additionalProperties: false`。只有明确声明的动态键值表可以开放任意键名,例如 `variables`、`http.headers`、`defaults.http.headers`、`expect.headers`、`cmd.env`。 契约校验和语义 validator 都必须返回 `ConfigValidationIssue[]`,不要在 validator 内直接拼接最终用户错误字符串。最终错误由 `formatConfigIssues()` 统一渲染,错误路径需要尽量包含 `targetName` 或 `defaults`/root 路径。 @@ -425,7 +429,7 @@ TcpChecker implements Checker | 方法 | 用途 | | ------------------------------------------ | ----------------------------------------------------------- | -| `syncTargets(targets)` | 启动期同步 targets(基于 name 做 upsert + delete 事务) | +| `syncTargets(targets)` | 启动期同步 targets(基于配置 `id` 做 upsert + delete 事务) | | `insertCheckResult()` | 写入单条检查结果 | | `getTargets()` | 查询全部 targets(default 分组优先排序) | | `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果(单次 SQL 聚合) | @@ -459,8 +463,8 @@ TcpChecker implements Checker **Schema**: -- `targets` 表:name(UNIQUE)、type、target(展示摘要)、config(JSON)、interval_ms、timeout_ms、expect(JSON)、grp -- `check_results` 表:target_id(FK CASCADE)、timestamp、matched(0/1)、duration_ms、status_detail、failure(JSON) +- `targets` 表:id(TEXT PRIMARY KEY,配置 target id)、name(展示名称)、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)` ### 1.9 拨测引擎 @@ -469,7 +473,7 @@ TcpChecker implements Checker - **并发控制**:`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`,默认 20),`acquire()` 阻塞等待 - **Runner 选择**:`engine.runCheck()` 通过 `checkerRegistry.get(target.type)` 获取 checker,并调用 `checker.execute(target, { signal })` - **超时控制**:`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abort;checker 必须使用 `CheckerContext.signal` 感知超时,HTTP 将 signal 传给 `fetch()`,Cmd 在 signal abort 时 `proc.kill()` -- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 通过 `targetNameToId` 缓存 name→id 映射 +- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLite,engine 基于配置 target id 确认目标仍存在 - **异常可观测**:`probeGroup()` 对 `Promise.allSettled` 的 rejected 结果通过索引关联 target,并写入 `phase:"internal"` 的失败记录 - **数据清理**:当 `retentionMs > 0` 时,engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据 - **生命周期**:`start()`/`stop()` 管理定时器(含调度定时器和清理定时器),`stop()` 清理所有 `setInterval` diff --git a/README.md b/README.md index e305b16..fb62703 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ runtime: maxConcurrentChecks: 20 retention: "7d" +variables: + env_name: "生产" + base_url: "https://api.example.com" + api_token: "Bearer demo-token" + sqlite_url: "sqlite:///path/to/db.sqlite" + defaults: interval: "30s" timeout: "10s" @@ -78,7 +84,8 @@ defaults: maxOutputBytes: "1MB" targets: - - name: "Baidu" + - id: "baidu-home" + name: "Baidu" type: http http: url: "https://www.baidu.com" @@ -86,10 +93,13 @@ targets: status: [200] maxDurationMs: 10000 - - name: "JSON API 示例" + - id: "json-api" + name: "${env_name} JSON API 示例" type: http http: - url: "https://httpbin.org/json" + url: "${base_url}/json" + headers: + Authorization: "${api_token|Bearer fallback-token}" expect: status: [200] headers: @@ -100,7 +110,8 @@ targets: path: "$.slideshow.title" equals: "Sample Slide Show" - - name: "Bun 脚本检查" + - id: "bun-script" + name: "Bun 脚本检查" type: cmd cmd: exec: "bun" @@ -110,10 +121,11 @@ targets: stdout: - contains: "ok" - - name: "SQLite 数据库检查" + - id: "sqlite-active-users" + name: "SQLite 数据库检查" type: db db: - url: "sqlite:///path/to/db.sqlite" + url: "${sqlite_url}" query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'" expect: maxDurationMs: 5000 @@ -150,17 +162,28 @@ targets: | `cmd.maxOutputBytes` | 输出最大字节数 | `100MB` | | `cmd.cwd` | 默认工作目录(相对于配置文件所在目录) | `.` | +#### variables — 配置变量(可省略) + +`variables` 是顶层动态键值表,key 必须符合 `[a-zA-Z_][a-zA-Z0-9_]*`,value 仅支持 string、number、boolean。target 中的字符串值可引用变量: + +- `${key}`:引用 variables 或环境变量 +- `${key|default}`:变量和环境变量都不存在时使用默认值,第一个 `|` 后的内容为默认值 +- `$${key}`:转义输出字面量 `${key}` + +解析优先级为 `variables -> process.env -> 默认值`。字段值完整等于单个变量引用时会保留 number/boolean/string 类型;部分拼接时统一转为字符串。变量替换仅作用于 `targets`,且不会替换 `id` 和 `type` 字段。 + #### targets — 拨测目标列表(必填) 每个 target 的通用字段: -| 字段 | 说明 | 必填 | -| ---------- | ----------------------------- | -------------------- | -| `name` | 目标名称(全局唯一) | 是 | -| `type` | 目标类型:`http`、`cmd`、`db` | 是 | -| `group` | 分组名称 | 否,默认 `"default"` | -| `interval` | 覆盖全局拨测间隔 | 否 | -| `timeout` | 覆盖全局超时时间 | 否 | +| 字段 | 说明 | 必填 | +| ---------- | ---------------------------------------------------------- | -------------------- | +| `id` | 目标唯一标识,支持字母数字、下划线、连字符,不参与变量替换 | 是 | +| `name` | 展示名称,支持变量替换;省略时使用 `id` | 否 | +| `type` | 目标类型:`http`、`cmd`、`db` | 是 | +| `group` | 分组名称 | 否,默认 `"default"` | +| `interval` | 覆盖全局拨测间隔 | 否 | +| `timeout` | 覆盖全局超时时间 | 否 | **HTTP 类型** (`type: http`) @@ -218,7 +241,7 @@ targets: **JSON Schema**:仓库根目录导出 `probe-config.schema.json`,在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 即可在编辑器中获得提示和校验。 -> **注意:** 配置校验在启动时执行,非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`)外,未知字段会导致启动失败,请使用 YAML 注释。 +> **注意:** 配置校验在启动时执行,非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`、`variables`)外,未知字段会导致启动失败,请使用 YAML 注释。 ## 目标状态判定 diff --git a/openspec/specs/config-variables/spec.md b/openspec/specs/config-variables/spec.md new file mode 100644 index 0000000..51a45a9 --- /dev/null +++ b/openspec/specs/config-variables/spec.md @@ -0,0 +1,170 @@ +## Purpose + +定义配置文件的变量定义、引用、解析和替换机制,支持集中管理共享值和环境变量注入。 + +## Requirements + +### Requirement: variables 段定义 +配置文件 SHALL 支持可选的顶层 `variables` 段,用于定义变量键值对。variables 的 key SHALL 符合 `[a-zA-Z_][a-zA-Z0-9_]*` 命名规则。variables 的 value SHALL 仅支持 string、number、boolean 三种类型,MUST NOT 支持 null、array、object。variables 段自身 MUST NOT 支持引用其他变量或环境变量(值为纯字面量)。 + +#### Scenario: 定义字符串变量 +- **WHEN** 配置文件包含 `variables: { base_url: "https://api.example.com" }` +- **THEN** 系统 SHALL 解析 base_url 为字符串类型变量,值为 "https://api.example.com" + +#### Scenario: 定义数字变量 +- **WHEN** 配置文件包含 `variables: { port: 5432 }` +- **THEN** 系统 SHALL 解析 port 为 number 类型变量,值为 5432 + +#### Scenario: 定义布尔变量 +- **WHEN** 配置文件包含 `variables: { ssl_enabled: true }` +- **THEN** 系统 SHALL 解析 ssl_enabled 为 boolean 类型变量,值为 true + +#### Scenario: 变量值为 null 报错 +- **WHEN** 配置文件包含 `variables: { empty: null }` +- **THEN** 系统 SHALL 以配置错误退出,提示 variables 的值不允许为 null + +#### Scenario: 变量值为数组报错 +- **WHEN** 配置文件包含 `variables: { list: [1, 2, 3] }` +- **THEN** 系统 SHALL 以配置错误退出,提示 variables 的值不允许为 array + +#### Scenario: 变量值为对象报错 +- **WHEN** 配置文件包含 `variables: { obj: { a: 1 } }` +- **THEN** 系统 SHALL 以配置错误退出,提示 variables 的值不允许为 object + +#### Scenario: 变量 key 不合法报错 +- **WHEN** 配置文件包含 `variables: { "123start": "value" }` +- **THEN** 系统 SHALL 以配置错误退出,提示变量名不符合命名规则 + +#### Scenario: 不定义 variables 段 +- **WHEN** 配置文件不包含 variables 段 +- **THEN** 系统 SHALL 正常启动,targets 中的 `${...}` 引用仅从环境变量查找 + +### Requirement: 变量引用语法 +targets 中的字符串值 SHALL 支持 `${key}` 语法引用变量。系统 SHALL 支持 `${key|default}` 语法设置默认值,其中第一个 `|` 为分隔符,后续 `|` 属于默认值内容。系统 SHALL 支持 `$${...}` 转义语法输出字面量 `${...}`。 + +#### Scenario: 简单变量引用 +- **WHEN** target 字段值为 `"${base_url}/health"` 且 variables 中定义 `base_url: "https://api.example.com"` +- **THEN** 系统 SHALL 将该字段替换为 `"https://api.example.com/health"` + +#### Scenario: 带默认值的变量引用 +- **WHEN** target 字段值为 `"${DB_PORT|5432}"` 且 variables 和环境变量中均不存在 DB_PORT +- **THEN** 系统 SHALL 将该字段替换为使用默认值(类型推断后为 number 5432) + +#### Scenario: 默认值包含管道符 +- **WHEN** target 字段值为 `"${PATTERN|foo|bar}"` 且变量不存在 +- **THEN** 系统 SHALL 使用 `"foo|bar"` 作为默认值(第一个 `|` 为分隔符) + +#### Scenario: 转义语法 +- **WHEN** target 字段值为 `"Hello $${name}"` +- **THEN** 系统 SHALL 输出 `"Hello ${name}"`,不进行变量替换 + +#### Scenario: 多个变量引用 +- **WHEN** target 字段值为 `"${protocol}://${host}:${port}/api"` +- **THEN** 系统 SHALL 逐个解析并替换所有变量引用,结果为拼接后的字符串 + +#### Scenario: 无变量引用的字符串 +- **WHEN** target 字段值为 `"https://example.com"` 且不含 `${...}` 模式 +- **THEN** 系统 SHALL 保持原样,不做任何处理 + +### Requirement: 变量解析优先级 +系统 SHALL 按以下优先级解析变量引用:variables 定义 → 环境变量 → 默认值。如果三者均不存在,系统 SHALL 以配置错误退出。 + +#### Scenario: variables 优先于环境变量 +- **WHEN** variables 中定义 `port: 5432` 且环境变量 `port=3000` 也存在 +- **THEN** 系统 SHALL 使用 variables 中的值 5432 + +#### Scenario: 环境变量作为 fallback +- **WHEN** variables 中未定义 `DB_HOST` 但环境变量 `DB_HOST=localhost` 存在 +- **THEN** 系统 SHALL 使用环境变量的值 "localhost" + +#### Scenario: 默认值作为最终 fallback +- **WHEN** variables 和环境变量中均不存在 `CACHE_TTL`,且引用为 `${CACHE_TTL|60}` +- **THEN** 系统 SHALL 使用默认值(类型推断后为 number 60) + +#### Scenario: 变量未定义且无默认值报错 +- **WHEN** target 字段引用 `${MISSING_VAR}` 且 variables、环境变量中均不存在,也未设置默认值 +- **THEN** 系统 SHALL 以配置错误退出,提示该变量未定义 + +### Requirement: 完整引用类型保留 +当字段值仅包含单个变量引用(完整引用)时,系统 SHALL 保留变量的原始类型。完整引用的判定为:字段值去掉首尾空白后严格匹配单个 `${key}` 或 `${key|default}` 模式且无其他字符。 + +#### Scenario: 完整引用 number 变量 +- **WHEN** target 字段值为 `"${port}"` 且 variables 中 `port: 5432` +- **THEN** 系统 SHALL 将该字段替换为 number 类型的 5432 + +#### Scenario: 完整引用 boolean 变量 +- **WHEN** target 字段值为 `"${ssl}"` 且 variables 中 `ssl: true` +- **THEN** 系统 SHALL 将该字段替换为 boolean 类型的 true + +#### Scenario: 完整引用 string 变量 +- **WHEN** target 字段值为 `"${host}"` 且 variables 中 `host: "example.com"` +- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "example.com" + +#### Scenario: 部分引用强制为字符串 +- **WHEN** target 字段值为 `"port: ${port}"` 且 variables 中 `port: 5432` +- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "port: 5432" + +#### Scenario: 环境变量完整引用类型推断 +- **WHEN** target 字段值为 `"${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=5` +- **THEN** 系统 SHALL 将该字段替换为 number 类型的 5(类型推断) + +#### Scenario: 默认值完整引用类型推断 +- **WHEN** target 字段值为 `"${TIMEOUT|30}"` 且变量不存在 +- **THEN** 系统 SHALL 将该字段替换为 number 类型的 30(类型推断) + +#### Scenario: 默认值推断为 boolean +- **WHEN** target 字段值为 `"${IGNORE_SSL|false}"` 且变量不存在 +- **THEN** 系统 SHALL 将该字段替换为 boolean 类型的 false + +#### Scenario: 默认值无法推断保持字符串 +- **WHEN** target 字段值为 `"${HOST|localhost}"` 且变量不存在 +- **THEN** 系统 SHALL 将该字段替换为 string 类型的 "localhost" + +### Requirement: 替换范围限制 +变量替换 SHALL 仅作用于 targets 段。`id` 和 `type` 字段 MUST NOT 参与变量替换。`server`、`runtime`、`defaults` 段 MUST NOT 参与变量替换。系统 SHALL 递归遍历 target 对象树中所有字符串值进行替换(包括嵌套对象和数组元素中的字符串)。 + +#### Scenario: target 嵌套对象中的变量替换 +- **WHEN** target 配置 `http.headers.Authorization: "${token}"` 且 variables 中定义 `token: "Bearer abc"` +- **THEN** 系统 SHALL 将该 header 值替换为 "Bearer abc" + +#### Scenario: target 数组元素中的变量替换 +- **WHEN** target 配置 `cmd.args: ["--host", "${host}"]` 且 variables 中定义 `host: "localhost"` +- **THEN** 系统 SHALL 将数组第二个元素替换为 "localhost" + +#### Scenario: id 字段不替换 +- **WHEN** target 配置 `id: "${my_id}"` 且 variables 中定义 `my_id: "test"` +- **THEN** 系统 SHALL 保持 id 字段值为字面量 `"${my_id}"`,不进行替换 + +#### Scenario: type 字段不替换 +- **WHEN** target 配置 `type: "${checker_type}"` 且 variables 中定义 `checker_type: "http"` +- **THEN** 系统 SHALL 保持 type 字段值为字面量 `"${checker_type}"`,不进行替换 + +#### Scenario: defaults 段不替换 +- **WHEN** defaults 配置 `interval: "${default_interval}"` 且 variables 中定义 `default_interval: "30s"` +- **THEN** 系统 SHALL 保持 defaults.interval 为字面量 `"${default_interval}"`,不进行替换 + +#### Scenario: server 段不替换 +- **WHEN** server 配置 `host: "${server_host}"` 且 variables 中定义 `server_host: "0.0.0.0"` +- **THEN** 系统 SHALL 保持 server.host 为字面量 `"${server_host}"`,不进行替换 + +### Requirement: 变量替换错误报告 +变量替换阶段的错误 SHALL 作为 `ConfigValidationIssue` 输出,code 为 `unresolved-variable`。错误信息 SHALL 包含 target 索引、target id、字段路径和变量名。 + +#### Scenario: 单个变量缺失报错 +- **WHEN** targets[0] (id: "api-health") 的 http.url 引用 `${base_url}` 但变量不存在 +- **THEN** 系统 SHALL 输出包含 target 索引 0、id "api-health"、路径 "http.url"、变量名 "base_url" 的错误信息 + +#### Scenario: 多个变量缺失批量报错 +- **WHEN** 多个 target 的多个字段引用了不存在的变量 +- **THEN** 系统 SHALL 收集所有缺失变量错误后统一输出,而非遇到第一个就退出 + +### Requirement: 变量替换执行时机 +变量替换 SHALL 在 YAML 解析之后、schema 契约校验(AJV)之前执行。替换完成后的配置对象 SHALL 传入后续校验流程。 + +#### Scenario: 替换后通过 schema 校验 +- **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=5` +- **THEN** 系统 SHALL 先将该字段替换为 number 5,再进入 AJV 校验(期望 integer),校验通过 + +#### Scenario: 替换后未通过 schema 校验 +- **WHEN** target 配置 `http.maxRedirects: "${MAX_REDIRECTS}"` 且环境变量 `MAX_REDIRECTS=abc` +- **THEN** 系统 SHALL 先将该字段替换为 string "abc",再进入 AJV 校验(期望 integer),校验失败并报错 diff --git a/openspec/specs/probe-config/spec.md b/openspec/specs/probe-config/spec.md index 37203e5..736b4f6 100644 --- a/openspec/specs/probe-config/spec.md +++ b/openspec/specs/probe-config/spec.md @@ -5,21 +5,21 @@ ## Requirements ### Requirement: YAML 配置文件格式 -系统 SHALL 支持通过 YAML 配置文件定义全部运行参数,包括 server 配置、runtime 配置、checker 默认值和 typed target 列表(含可选 group 字段)。target MUST 使用 `type` 字段声明 checker 类型,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` 字段作为展示名称(缺省 fallback 到 id)。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` 字段。 #### Scenario: 完整配置文件解析 -- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets(含 group 字段)的 YAML 配置文件 +- **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targets(含 id、group 字段)的 YAML 配置文件 - **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner #### Scenario: 最简 HTTP 配置文件解析 -- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、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") +- **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) #### Scenario: 最简 cmd 配置文件解析 -- **WHEN** 系统读取只包含一个 `type: cmd` target 和 `cmd.exec` 的 YAML 配置文件 -- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB) +- **WHEN** 系统读取只包含一个 `type: cmd` target(含 `id` 和 `cmd.exec`)的 YAML 配置文件 +- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB, name fallback 到 id) #### Scenario: per-target 配置覆盖全局默认值 - **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段 @@ -34,8 +34,8 @@ - **THEN** 系统 SHALL 解析该字段并在执行时允许最多跟随 5 次重定向 #### Scenario: 最简 db 配置文件解析 -- **WHEN** 系统读取只包含一个 `type: db` target 和 `db.url` 的 YAML 配置文件 -- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default") +- **WHEN** 系统读取只包含一个 `type: db` target(含 `id` 和 `db.url`)的 YAML 配置文件 +- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段(interval=30s, timeout=10s, group="default", name fallback 到 id) #### Scenario: defaults.http.method 触发校验错误 - **WHEN** 配置文件中出现 `defaults.http.method` 字段 @@ -65,16 +65,16 @@ - **THEN** 系统 SHALL 以错误退出并提示文件不存在 ### Requirement: 配置校验 -系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。系统 SHALL 使用 TypeBox 定义配置契约和 raw config TypeScript 类型,由 Ajv 校验 TypeBox 生成的 JSON Schema,再执行启动期语义 validator。配置加载流程 SHALL 明确区分 `RawProbeConfig`、`ValidatedProbeConfig`、`ResolvedConfig` 三段生命周期。JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型、必填字段、枚举、数组与对象形状、数值范围和未知字段。语义 validator SHALL 覆盖契约不适合表达的业务规则,包括 target name 唯一性、checker type 注册状态、时长和大小解析、HTTP URL、正则可编译、JSONPath 子集和 XPath 可编译。 +系统 SHALL 在启动时对 YAML 配置进行完整校验,校验失败时以非零状态退出并输出清晰的错误信息。系统 SHALL 使用 TypeBox 定义配置契约和 raw config TypeScript 类型,由 Ajv 校验 TypeBox 生成的 JSON Schema,再执行启动期语义 validator。配置加载流程 SHALL 明确区分 `RawProbeConfig`、`ValidatedProbeConfig`、`ResolvedConfig` 三段生命周期,并在 YAML 解析之后、AJV 校验之前执行变量替换阶段。JSON Schema 契约 SHALL 覆盖业务无关的结构规则,包括字段类型、必填字段、枚举、数组与对象形状、数值范围和未知字段。语义 validator SHALL 覆盖契约不适合表达的业务规则,包括 target id 唯一性、id 命名规则校验、checker type 注册状态、时长和大小解析、HTTP URL、正则可编译、JSONPath 子集和 XPath 可编译。 契约校验和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息。 系统 SHALL 导出完整 `probe-config.schema.json`,该文件 SHALL 与运行期 TypeBox fragments 生成的 JSON Schema 保持一致,用于用户配置引用和编辑器提示。 -除 `headers`、`env` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。 +除 `headers`、`env`、`variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。 #### Scenario: target 缺少必填字段 -- **WHEN** YAML 中某个 target 缺少 name 或 type 字段 +- **WHEN** YAML 中某个 target 缺少 id 或 type 字段 - **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段 #### Scenario: HTTP target 缺少 url @@ -89,9 +89,13 @@ - **WHEN** YAML 中某个 target 的 type 不是已注册 checker 类型 - **THEN** 系统 SHALL 以错误退出,提示不支持的 target type 和当前支持的 type 列表 -#### Scenario: target name 重复 -- **WHEN** YAML 中存在两个 name 相同的 target -- **THEN** 系统 SHALL 以错误退出,提示重复的 name +#### Scenario: target id 重复 +- **WHEN** YAML 中存在两个 id 相同的 target +- **THEN** 系统 SHALL 以错误退出,提示重复的 id + +#### Scenario: target id 不合法 +- **WHEN** YAML 中某个 target 的 id 不符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 规则 +- **THEN** 系统 SHALL 以错误退出,提示 id 命名不合法 #### Scenario: group 字段类型校验 - **WHEN** YAML 中某个 target 的 `group` 字段不是字符串 @@ -223,15 +227,15 @@ #### Scenario: 配置生命周期分离 - **WHEN** 系统加载配置文件 -- **THEN** 系统 SHALL 按 `unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行契约校验、语义校验和运行期配置解析 +- **THEN** 系统 SHALL 按 `unknown -> 变量替换 -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行变量替换、契约校验、语义校验和运行期配置解析 #### Scenario: 结构化校验 issue -- **WHEN** 契约校验或语义 validator 发现非法配置 -- **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误 +- **WHEN** 契约校验、语义 validator 或变量替换阶段发现非法配置 +- **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误信息 #### Scenario: 导出配置 JSON Schema - **WHEN** 仓库生成或检查配置契约 -- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致 +- **THEN** 根目录 SHALL 存在 draft-07 `probe-config.schema.json`,且其内容 SHALL 与当前公共 fragments 和已注册 checker fragments 组装出的完整 schema 一致(包含 variables 段和 target 的 id/name 字段) 系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B`、`KB`、`MB` 和 `GB`。 #### Scenario: 解析 MB diff --git a/openspec/specs/probe-data-store/spec.md b/openspec/specs/probe-data-store/spec.md index b4727de..33d04fc 100644 --- a/openspec/specs/probe-data-store/spec.md +++ b/openspec/specs/probe-data-store/spec.md @@ -34,7 +34,7 @@ #### Scenario: 配置变更后重新同步 - **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启 -- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段) +- **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段) ### Requirement: check_results 表追加写入 系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。 diff --git a/openspec/specs/target-identity/spec.md b/openspec/specs/target-identity/spec.md new file mode 100644 index 0000000..f55a0d3 --- /dev/null +++ b/openspec/specs/target-identity/spec.md @@ -0,0 +1,51 @@ +## Purpose + +定义 target 的 id/name 双字段标识体系:id 作为唯一标识符,name 作为可选展示名称。 + +## Requirements + +### Requirement: target id 字段 +每个 target SHALL 包含必填的 `id` 字段作为唯一标识符。`id` SHALL 符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 命名规则。`id` MUST 在所有 targets 中全局唯一。`id` MUST NOT 参与变量替换。 + +#### Scenario: 合法 id +- **WHEN** target 配置 `id: "api-health"` +- **THEN** 系统 SHALL 使用该 id 作为 target 的唯一标识符 + +#### Scenario: id 包含下划线和连字符 +- **WHEN** target 配置 `id: "db_check-01"` +- **THEN** 系统 SHALL 使用该 id 作为 target 的唯一标识符 + +#### Scenario: id 缺失报错 +- **WHEN** target 未配置 `id` 字段 +- **THEN** 系统 SHALL 以配置错误退出,提示该 target 缺少 id 字段 + +#### Scenario: id 为空字符串报错 +- **WHEN** target 配置 `id: ""` +- **THEN** 系统 SHALL 以配置错误退出,提示 id 不能为空 + +#### Scenario: id 不合法报错 +- **WHEN** target 配置 `id: "_invalid"` 或 `id: "-start"` 或 `id: "has space"` +- **THEN** 系统 SHALL 以配置错误退出,提示 id 不符合命名规则 + +#### Scenario: id 重复报错 +- **WHEN** 两个 target 配置相同的 `id: "api-health"` +- **THEN** 系统 SHALL 以配置错误退出,提示 id 重复 + +### Requirement: target name 字段 +每个 target SHALL 支持可选的 `name` 字段作为展示名称。`name` 缺省时 SHALL fallback 到 `id` 的值作为展示名称。`name` SHALL 支持变量替换。`name` MUST NOT 要求全局唯一。 + +#### Scenario: 配置 name +- **WHEN** target 配置 `id: "api-health"` 和 `name: "API 健康检查"` +- **THEN** 系统 SHALL 使用 "API 健康检查" 作为展示名称 + +#### Scenario: name 使用变量 +- **WHEN** target 配置 `name: "${env} API 健康检查"` 且 variables 中 `env: "生产"` +- **THEN** 系统 SHALL 将展示名称解析为 "生产 API 健康检查" + +#### Scenario: name 缺省 fallback 到 id +- **WHEN** target 配置 `id: "api-health"` 但未配置 `name` +- **THEN** 系统 SHALL 使用 "api-health" 作为展示名称 + +#### Scenario: 多个 target 使用相同 name +- **WHEN** 两个 target 配置不同 id 但相同 `name: "健康检查"` +- **THEN** 系统 SHALL 接受该配置,不报错(name 不要求全局唯一) diff --git a/probe-config.schema.json b/probe-config.schema.json index 43a0700..98fe1bd 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -104,7 +104,7 @@ "additionalProperties": false, "type": "object", "required": [ - "name", + "id", "type", "http" ], @@ -403,6 +403,10 @@ "group": { "type": "string" }, + "id": { + "minLength": 1, + "type": "string" + }, "interval": { "type": "string" }, @@ -495,7 +499,7 @@ "additionalProperties": false, "type": "object", "required": [ - "name", + "id", "type", "cmd" ], @@ -635,6 +639,10 @@ "group": { "type": "string" }, + "id": { + "minLength": 1, + "type": "string" + }, "interval": { "type": "string" }, @@ -694,7 +702,7 @@ "additionalProperties": false, "type": "object", "required": [ - "name", + "id", "type", "db" ], @@ -860,6 +868,10 @@ "group": { "type": "string" }, + "id": { + "minLength": 1, + "type": "string" + }, "interval": { "type": "string" }, @@ -895,6 +907,24 @@ } ] } + }, + "variables": { + "type": "object", + "patternProperties": { + "^[a-zA-Z_][a-zA-Z0-9_]*$": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + } } }, "$id": "https://dial.local/probe-config.schema.json", diff --git a/probes.example.yaml b/probes.example.yaml index a4a4f77..180404a 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=./probe-config.schema.json + server: host: "127.0.0.1" port: 3000 @@ -14,10 +16,17 @@ defaults: cmd: maxOutputBytes: "1MB" +variables: + env_name: "演示" + httpbin_base: "https://httpbin.org" + api_token: "Bearer demo-token" + sqlite_url: "sqlite://:memory:" + targets: # ========== HTTP targets ========== - - name: "Baidu 首页可用" + - id: "baidu-home" + name: "Baidu 首页可用" type: http group: "搜索引擎" http: @@ -26,15 +35,17 @@ targets: status: [200] maxDurationMs: 5000 - - name: "JSON API — 完整流水线" + - id: "httpbin-json" + name: "${env_name} JSON API — 完整流水线" type: http group: "后端服务" interval: "1m" timeout: "15s" http: - url: "https://httpbin.org/json" + url: "${httpbin_base}/json" headers: Accept: "application/json" + Authorization: "${api_token|Bearer fallback-token}" expect: headers: Content-Type: @@ -49,10 +60,11 @@ targets: contains: "Wake" - regex: '"title"' - - name: "POST 接口测试" + - id: "httpbin-post" + name: "POST 接口测试" type: http http: - url: "https://httpbin.org/post" + url: "${httpbin_base}/post" method: POST headers: Content-Type: "application/json" @@ -69,7 +81,8 @@ targets: # ========== Cmd targets ========== - - name: "Bun 版本输出匹配" + - id: "bun-version" + name: "Bun 版本输出匹配" type: cmd group: "系统检查" cmd: @@ -80,7 +93,8 @@ targets: stdout: - match: "^\\d+\\.\\d+\\.\\d+" - - name: "多规则 stdout 顺序校验" + - id: "bun-stdout-rules" + name: "多规则 stdout 顺序校验" type: cmd interval: "5m" cmd: @@ -92,7 +106,8 @@ targets: - match: "\\d+\\.\\d+\\.\\d+" - contains: "healthy" - - name: "stderr 内容检查" + - id: "bun-stderr" + name: "stderr 内容检查" type: cmd cmd: exec: "bun" @@ -104,18 +119,20 @@ targets: # ========== DB targets ========== - - name: "SQLite 内存数据库连接测试" + - id: "sqlite-connect" + name: "SQLite 内存数据库连接测试" type: db group: "数据库" db: - url: "sqlite://:memory:" + url: "${sqlite_url}" expect: maxDurationMs: 1000 - - name: "SQLite 内存数据库多列结果校验" + - id: "sqlite-query" + name: "SQLite 内存数据库多列结果校验" type: db db: - url: "sqlite://:memory:" + url: "${sqlite_url}" query: "SELECT 1 as id, 'Alice' as name, 'engineer' as role" expect: rowCount: diff --git a/src/server/checker/config-loader.ts b/src/server/checker/config-loader.ts index 839787f..c05181f 100644 --- a/src/server/checker/config-loader.ts +++ b/src/server/checker/config-loader.ts @@ -1,3 +1,5 @@ +import { isNumber, isPlainObject, isString } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; import { dirname, resolve } from "node:path"; import type { ConfigValidationIssue } from "./schema/issues"; @@ -8,6 +10,7 @@ import { issue, throwConfigIssues } from "./schema/issues"; import { asValidatedConfig, type RawProbeConfig } from "./schema/types"; import { validateProbeConfigContract } from "./schema/validate"; import { parseDuration } from "./utils"; +import { resolveVariables } from "./variables"; const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 3000; @@ -41,11 +44,17 @@ export async function loadConfig(configPath: string): Promise { throw new Error("配置文件内容为空或格式无效"); } - const contractResult = validateProbeConfigContract(parsed, checkerRegistry); - if (contractResult.config === null && !canRunSemanticValidation(parsed)) { + const variableResult = resolveVariables(parsed); + if (variableResult.issues.length > 0) { + throwConfigIssues(dedupeIssues(variableResult.issues)); + } + + const resolvedVariablesConfig = variableResult.config; + const contractResult = validateProbeConfigContract(resolvedVariablesConfig, checkerRegistry); + if (contractResult.config === null && !canRunSemanticValidation(resolvedVariablesConfig)) { throwConfigIssues(contractResult.issues); } - const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig; + const semanticInput = (contractResult.config ?? resolvedVariablesConfig) as RawProbeConfig; const validationIssues = validateConfig(semanticInput); const allIssues = [...contractResult.issues, ...validationIssues]; @@ -88,14 +97,14 @@ export async function loadConfig(configPath: string): Promise { } function canRunSemanticValidation(value: unknown): boolean { - return typeof value === "object" && value !== null; + return isPlainObject(value); } function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] { const seen = new Set(); const result: ConfigValidationIssue[] = []; for (const item of issues) { - const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`; + const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}:${item.targetId ?? ""}`; if (seen.has(key)) continue; seen.add(key); result.push(item); @@ -103,16 +112,12 @@ function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] return result; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export { parseDuration } from "./utils"; function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number { if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS; if ( - typeof runtime.maxConcurrentChecks !== "number" || + !isNumber(runtime.maxConcurrentChecks) || !Number.isInteger(runtime.maxConcurrentChecks) || runtime.maxConcurrentChecks <= 0 ) @@ -145,29 +150,36 @@ function resolveTarget( function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; - if (!Array.isArray(config.targets) || config.targets.length === 0) { + if (!isArray(config.targets) || config.targets.length === 0) { issues.push(issue("required", "targets", "配置文件必须包含至少一个 target")); return issues; } - const names = new Set(); + const ids = new Set(); const supportedTypes = checkerRegistry.supportedTypes; for (let i = 0; i < config.targets.length; i++) { const rawTarget = config.targets[i] as unknown; - if (!isRecord(rawTarget)) { + if (!isPlainObject(rawTarget)) { issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象")); continue; } - const raw = rawTarget; + const raw = rawTarget as Record; - const name = raw["name"]; - if (!name || typeof name !== "string" || name.trim() === "") { - issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段")); + const id: unknown = raw["id"]; + if (!isString(id) || id.trim() === "") { + issues.push(issue("required", `targets[${i}].id`, "缺少 id 字段")); continue; } - const type = raw["type"]; - if (!type || typeof type !== "string") { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) { + issues.push(issue("invalid-format", `targets[${i}].id`, "id 不符合命名规则", id)); + } + + const nameValue: unknown = raw["name"]; + const name = isString(nameValue) ? nameValue : id; + + const type: unknown = raw["type"]; + if (!isString(type)) { issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name)); continue; } @@ -183,16 +195,16 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] { ); } - const group = raw["group"]; - if (group !== undefined && typeof group !== "string") { + const group: unknown = raw["group"]; + if (group !== undefined && !isString(group)) { issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name)); } - if (names.has(name)) { - issues.push(issue("duplicate-name", `targets[${i}].name`, `target name 重复: "${name}"`, name)); + if (ids.has(id)) { + issues.push(issue("duplicate-id", `targets[${i}].id`, `target id 重复: "${id}"`, name)); } - names.add(name); + ids.add(id); } for (const checker of checkerRegistry.definitions) { @@ -202,22 +214,29 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] { validateDurationValue(config.defaults?.interval, "defaults.interval", issues); validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues); validateDurationValue( - typeof config.runtime?.retention === "string" ? config.runtime.retention : undefined, + isString(config.runtime?.retention) ? config.runtime.retention : undefined, "runtime.retention", issues, ); for (let i = 0; i < config.targets.length; i++) { const target = config.targets[i] as unknown; - if (!isRecord(target)) continue; - const targetName = typeof target["name"] === "string" ? target["name"] : undefined; + if (!isPlainObject(target)) continue; + const targetRecord = target as Record; + const targetNameValue: unknown = targetRecord["name"]; + const targetIdValue: unknown = targetRecord["id"]; + const targetName = isString(targetNameValue) + ? targetNameValue + : isString(targetIdValue) + ? targetIdValue + : undefined; validateDurationValue( - typeof target["interval"] === "string" ? target["interval"] : undefined, + isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined, `targets[${i}].interval`, issues, targetName, ); validateDurationValue( - typeof target["timeout"] === "string" ? target["timeout"] : undefined, + isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined, `targets[${i}].timeout`, issues, targetName, diff --git a/src/server/checker/engine.ts b/src/server/checker/engine.ts index cc208b1..b4dc52e 100644 --- a/src/server/checker/engine.ts +++ b/src/server/checker/engine.ts @@ -12,7 +12,7 @@ export class ProbeEngine { private retentionMs: number; private semaphore: Semaphore; private store: ProbeStore; - private targetNameToId = new Map(); + private targetIds = new Set(); private targets: ResolvedTargetBase[]; private timers: Array> = []; @@ -77,7 +77,7 @@ export class ProbeEngine { failure: errorFailure("internal", "engine", formatReason(result.reason)), matched: false, statusDetail: null, - targetName: target.name, + targetId: target.id, timestamp: new Date().toISOString(), }); } @@ -85,9 +85,9 @@ export class ProbeEngine { } private refreshCache(): void { - this.targetNameToId.clear(); + this.targetIds.clear(); for (const target of this.store.getTargets()) { - this.targetNameToId.set(target.name, target.id); + this.targetIds.add(target.id); } } @@ -104,15 +104,14 @@ export class ProbeEngine { } private writeResult(result: CheckResult): void { - const targetId = this.targetNameToId.get(result.targetName); - if (!targetId) return; + if (!this.targetIds.has(result.targetId)) return; this.store.insertCheckResult({ durationMs: result.durationMs, failure: result.failure, matched: result.matched, statusDetail: result.statusDetail, - targetId, + targetId: result.targetId, timestamp: result.timestamp, }); } diff --git a/src/server/checker/expect/failure.ts b/src/server/checker/expect/failure.ts index 2f93447..95b542a 100644 --- a/src/server/checker/expect/failure.ts +++ b/src/server/checker/expect/failure.ts @@ -1,3 +1,6 @@ +import { isString } from "es-toolkit"; +import { isObject } from "es-toolkit/compat"; + import type { CheckFailure } from "../types"; export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure { @@ -29,7 +32,7 @@ export function mismatchFailure( export function truncateActual(value: unknown, maxLen = 200): unknown { if (value === undefined || value === null) return value; - const str = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : undefined; + const str = isString(value) ? value : isObject(value) ? JSON.stringify(value) : undefined; if (str === undefined) return value; if (str.length <= maxLen) return value; return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`; diff --git a/src/server/checker/expect/operator.ts b/src/server/checker/expect/operator.ts index 37d862a..35bbe60 100644 --- a/src/server/checker/expect/operator.ts +++ b/src/server/checker/expect/operator.ts @@ -1,4 +1,5 @@ import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; import type { ExpectOperator, ExpectValue } from "../types"; @@ -14,7 +15,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean { break; case "empty": { const isEmpty = - isNil(actual) || actual === "" || (Array.isArray(actual) && actual.length === 0) || isEmptyObject(actual); + isNil(actual) || actual === "" || (isArray(actual) && actual.length === 0) || isEmptyObject(actual); if (expected !== isEmpty) return false; break; } @@ -67,7 +68,7 @@ export function evaluateJsonPath(json: unknown, path: string): unknown { if (bracketMatch) { current = (current as Record)?.[bracketMatch[1]!]; const idx = parseInt(bracketMatch[2]!, 10); - if (!Array.isArray(current) || idx >= current.length) return undefined; + if (!isArray(current) || idx >= current.length) return undefined; current = current[idx]; } else { if (current === null || current === undefined) return undefined; diff --git a/src/server/checker/expect/validate-operator.ts b/src/server/checker/expect/validate-operator.ts index 33ad8c7..cadba42 100644 --- a/src/server/checker/expect/validate-operator.ts +++ b/src/server/checker/expect/validate-operator.ts @@ -1,3 +1,6 @@ +import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; + import type { ConfigValidationIssue } from "../schema/issues"; import type { JsonValue } from "../types"; @@ -9,17 +12,17 @@ const OPERATOR_KEY_SET = new Set(OperatorKeys); export function isJsonValue(value: unknown): value is JsonValue { if (value === null) return true; - if (typeof value === "string" || typeof value === "boolean") return true; - if (typeof value === "number") return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - if (typeof value === "object") { - return Object.values(value as Record).every(isJsonValue); + if (isString(value) || isBoolean(value)) return true; + if (isNumber(value)) return Number.isFinite(value); + if (isArray(value)) return value.every(isJsonValue); + if (isPlainObject(value)) { + return Object.values(value).every(isJsonValue); } return false; } export function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return isPlainObject(value); } export function validateOperatorObject( @@ -54,21 +57,21 @@ export function validateOperatorValue( ): ConfigValidationIssue[] { switch (key) { case "contains": - return typeof value === "string" ? [] : [issue("invalid-type", path, "必须为字符串", targetName)]; + return isString(value) ? [] : [issue("invalid-type", path, "必须为字符串", targetName)]; case "empty": case "exists": - return typeof value === "boolean" ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)]; + return isBoolean(value) ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)]; case "equals": return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)]; case "gt": case "gte": case "lt": case "lte": - return typeof value === "number" && Number.isFinite(value) + return isNumber(value) && Number.isFinite(value) ? [] : [issue("invalid-type", path, "必须为有限数字", targetName)]; case "match": - if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)]; + if (!isString(value)) return [issue("invalid-type", path, "必须为字符串", targetName)]; try { new RegExp(value); } catch { diff --git a/src/server/checker/runner/cmd/execute.ts b/src/server/checker/runner/cmd/execute.ts index e260d92..21a335a 100644 --- a/src/server/checker/runner/cmd/execute.ts +++ b/src/server/checker/runner/cmd/execute.ts @@ -41,7 +41,7 @@ export class CommandChecker implements CheckerDefinition failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)), matched: false, statusDetail: null, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -74,7 +74,7 @@ export class CommandChecker implements CheckerDefinition failure: errorFailure("exitCode", "execution", "输出读取失败"), matched: false, statusDetail: null, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -90,7 +90,7 @@ export class CommandChecker implements CheckerDefinition failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`), matched: false, statusDetail: `exitCode=${exitCode}`, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -101,7 +101,7 @@ export class CommandChecker implements CheckerDefinition failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`), matched: false, statusDetail: null, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -113,7 +113,7 @@ export class CommandChecker implements CheckerDefinition failure: exitCodeResult.failure, matched: false, statusDetail: `exitCode=${exitCode}`, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -125,7 +125,7 @@ export class CommandChecker implements CheckerDefinition failure: durationResult.failure, matched: false, statusDetail: `exitCode=${exitCode}`, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -138,7 +138,7 @@ export class CommandChecker implements CheckerDefinition failure: stdoutResult.failure, matched: false, statusDetail: `exitCode=${exitCode}`, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -152,7 +152,7 @@ export class CommandChecker implements CheckerDefinition failure: stderrResult.failure, matched: false, statusDetail: `exitCode=${exitCode}`, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -163,7 +163,7 @@ export class CommandChecker implements CheckerDefinition failure: null, matched: true, statusDetail: `exitCode=${exitCode}`, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -189,8 +189,9 @@ export class CommandChecker implements CheckerDefinition }, expect: target.expect as CommandExpectConfig | undefined, group: target.group ?? "default", + id: t.id, intervalMs: context.defaultIntervalMs, - name: t.name, + name: t.name ?? t.id, timeoutMs: context.defaultTimeoutMs, type: "cmd", } satisfies ResolvedCommandTarget; diff --git a/src/server/checker/runner/cmd/validate.ts b/src/server/checker/runner/cmd/validate.ts index 26bd308..bb51f14 100644 --- a/src/server/checker/runner/cmd/validate.ts +++ b/src/server/checker/runner/cmd/validate.ts @@ -1,3 +1,6 @@ +import { isNumber, isPlainObject, isString } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; + import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; @@ -7,7 +10,8 @@ import { parseSize } from "../../utils"; export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] { const issues: ConfigValidationIssue[] = []; - const defaults = isRecord(input.defaults) && isRecord(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined; + const defaults = + isPlainObject(input.defaults) && isPlainObject(input.defaults["cmd"]) ? input.defaults["cmd"] : undefined; if (isSizeInput(defaults?.["maxOutputBytes"])) { issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.cmd.maxOutputBytes")); @@ -15,7 +19,7 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali for (let i = 0; i < input.targets.length; i++) { const target = input.targets[i] as unknown; - if (!isRecord(target)) continue; + if (!isPlainObject(target)) continue; if (target["type"] !== "cmd") continue; issues.push(...validateCommandTarget(target, `targets[${i}]`)); } @@ -24,25 +28,22 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali } function getTargetName(target: Record): string | undefined { - return typeof target["name"] === "string" ? target["name"] : undefined; + if (isString(target["name"])) return target["name"]; + return isString(target["id"]) ? target["id"] : undefined; } function isNonNegativeFiniteNumber(value: unknown): boolean { - return typeof value === "number" && Number.isFinite(value) && value >= 0; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return isNumber(value) && Number.isFinite(value) && value >= 0; } function isSizeInput(value: unknown): value is number | string { - return typeof value === "number" || typeof value === "string"; + return isNumber(value) || isString(value); } function validateCommandExpect(target: Record, path: string): ConfigValidationIssue[] { const targetName = getTargetName(target); const expect = target["expect"]; - if (expect === undefined || expect === null || !isRecord(expect)) return []; + if (expect === undefined || expect === null || !isPlainObject(expect)) return []; const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); if (expect["stdout"] !== undefined) { @@ -61,12 +62,12 @@ function validateCommandTarget(target: Record, path: string): C const issues: ConfigValidationIssue[] = []; const targetName = getTargetName(target); const cmd = target["cmd"]; - if (!isRecord(cmd)) { + if (!isPlainObject(cmd)) { issues.push(issue("required", joinPath(path, "cmd"), "缺少 cmd.exec 字段", targetName)); issues.push(...validateCommandExpect(target, path)); return issues; } - if (typeof cmd["exec"] !== "string" || cmd["exec"].trim() === "") { + if (!isString(cmd["exec"]) || cmd["exec"].trim() === "") { issues.push(issue("required", joinPath(joinPath(path, "cmd"), "exec"), "缺少 cmd.exec 字段", targetName)); } if (isSizeInput(cmd["maxOutputBytes"])) { @@ -88,6 +89,6 @@ function validateSizeValue(value: number | string, path: string, targetName?: st } function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] { - if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)]; + if (!isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)]; return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName)); } diff --git a/src/server/checker/runner/db/execute.ts b/src/server/checker/runner/db/execute.ts index 8babd5a..848b6ca 100644 --- a/src/server/checker/runner/db/execute.ts +++ b/src/server/checker/runner/db/execute.ts @@ -1,5 +1,6 @@ import { SQL } from "bun"; import { isError } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; @@ -50,7 +51,7 @@ export class DbChecker implements CheckerDefinition { failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)), matched: false, statusDetail: null, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -65,7 +66,7 @@ export class DbChecker implements CheckerDefinition { failure: durationResult.failure, matched: false, statusDetail: "connected", - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -74,7 +75,7 @@ export class DbChecker implements CheckerDefinition { failure: null, matched: true, statusDetail: "connected", - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -90,7 +91,7 @@ export class DbChecker implements CheckerDefinition { failure: errorFailure("query", "query", isError(error) ? error.message : String(error)), matched: false, statusDetail: null, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -104,7 +105,7 @@ export class DbChecker implements CheckerDefinition { failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`), matched: false, statusDetail: null, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -116,8 +117,8 @@ export class DbChecker implements CheckerDefinition { durationMs, failure: durationResult.failure, matched: false, - statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, - targetName: t.name, + statusDetail: `${isArray(rows) ? rows.length : 0} rows`, + targetId: t.id, timestamp, }; } @@ -130,8 +131,8 @@ export class DbChecker implements CheckerDefinition { durationMs, failure: rowCountResult.failure, matched: false, - statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, - targetName: t.name, + statusDetail: `${isArray(rows) ? rows.length : 0} rows`, + targetId: t.id, timestamp, }; } @@ -145,8 +146,8 @@ export class DbChecker implements CheckerDefinition { durationMs, failure: rowsResult.failure, matched: false, - statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, - targetName: t.name, + statusDetail: `${isArray(rows) ? rows.length : 0} rows`, + targetId: t.id, timestamp, }; } @@ -156,8 +157,8 @@ export class DbChecker implements CheckerDefinition { durationMs, failure: null, matched: true, - statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, - targetName: t.name, + statusDetail: `${isArray(rows) ? rows.length : 0} rows`, + targetId: t.id, timestamp, }; } finally { @@ -181,8 +182,9 @@ export class DbChecker implements CheckerDefinition { }, expect: target.expect as DbExpectConfig | undefined, group: target.group ?? "default", + id: t.id, intervalMs: context.defaultIntervalMs, - name: t.name, + name: t.name ?? t.id, timeoutMs: context.defaultTimeoutMs, type: "db", } satisfies ResolvedDbTarget; diff --git a/src/server/checker/runner/db/expect.ts b/src/server/checker/runner/db/expect.ts index 9b94dbd..f1f8c76 100644 --- a/src/server/checker/runner/db/expect.ts +++ b/src/server/checker/runner/db/expect.ts @@ -1,3 +1,6 @@ +import { isPlainObject } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; + import type { ExpectResult } from "../../expect/types"; import type { ExpectOperator, ExpectValue } from "../../types"; @@ -5,7 +8,7 @@ import { mismatchFailure } from "../../expect/failure"; import { checkExpectValue } from "../../expect/operator"; export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult { - const actual = Array.isArray(rows) ? rows.length : 0; + const actual = isArray(rows) ? rows.length : 0; const matched = checkExpectValue(actual, op); if (!matched) { return { @@ -17,7 +20,7 @@ export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult { } export function checkRows(rows: unknown, rules: Array>): ExpectResult { - if (!Array.isArray(rows)) { + if (!isArray(rows)) { return { failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"), matched: false, @@ -34,7 +37,7 @@ export function checkRows(rows: unknown, rules: Array | undefined; - if (!row || typeof row !== "object" || Array.isArray(row)) { + if (!isPlainObject(row)) { return { failure: mismatchFailure("row", `rows[${i}]`, "object", row, `第 ${i + 1} 行不是对象`), matched: false, diff --git a/src/server/checker/runner/db/validate.ts b/src/server/checker/runner/db/validate.ts index 3811ba2..a297f5e 100644 --- a/src/server/checker/runner/db/validate.ts +++ b/src/server/checker/runner/db/validate.ts @@ -1,3 +1,6 @@ +import { isNumber, isPlainObject, isString } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; + import type { ConfigValidationIssue } from "../../schema/issues"; import type { CheckerValidationInput } from "../types"; @@ -10,7 +13,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio for (let i = 0; i < input.targets.length; i++) { const target = input.targets[i] as unknown; - if (!isRecord(target)) continue; + if (!isPlainObject(target)) continue; if (target["type"] !== "db") continue; issues.push(...validateDbTarget(target, `targets[${i}]`)); } @@ -22,28 +25,29 @@ function collectRowOperators(rows: unknown[], path: string, targetName?: string) const issues: ConfigValidationIssue[] = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]!; - if (!isRecord(row)) { + if (!isPlainObject(row)) { issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName)); continue; } for (const [col, value] of Object.entries(row)) { const colPath = `${path}[${i}].${col}`; - if (isRecord(value) && Object.keys(value).some((k) => k === "match")) { + if (isPlainObject(value) && Object.keys(value).some((k) => k === "match")) { // 检查 match 正则 - const match = value["match"]; - if (typeof match === "string") { + const valueRecord = value as Record; + const match: unknown = valueRecord["match"]; + if (isString(match)) { try { new RegExp(match); } catch { issues.push(issue("invalid-regex", colPath, "正则不合法", targetName)); } - if (typeof match === "string" && isUnsafeRegex(match)) { + if (isUnsafeRegex(match)) { issues.push(issue("unsafe-regex", colPath, "正则存在 ReDoS 风险", targetName)); } } } // 校验 operator 对象 - if (isRecord(value)) { + if (isPlainObject(value)) { issues.push(...validateOperatorObject(value, colPath, targetName, { requireAtLeastOne: false })); } } @@ -52,21 +56,18 @@ function collectRowOperators(rows: unknown[], path: string, targetName?: string) } function getTargetName(target: Record): string | undefined { - return typeof target["name"] === "string" ? target["name"] : undefined; + if (isString(target["name"])) return target["name"]; + return isString(target["id"]) ? target["id"] : undefined; } function isNonNegativeFiniteNumber(value: unknown): boolean { - return typeof value === "number" && Number.isFinite(value) && value >= 0; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return isNumber(value) && Number.isFinite(value) && value >= 0; } function validateDbExpect(target: Record, path: string): ConfigValidationIssue[] { const targetName = getTargetName(target); const expect = target["expect"]; - if (expect === undefined || expect === null || !isRecord(expect)) return []; + if (expect === undefined || expect === null || !isPlainObject(expect)) return []; const issues: ConfigValidationIssue[] = []; const expectPath = joinPath(path, "expect"); @@ -79,7 +80,7 @@ function validateDbExpect(target: Record, path: string): Config } if (expect["rows"] !== undefined) { - if (!Array.isArray(expect["rows"])) { + if (!isArray(expect["rows"])) { issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName)); } else { issues.push(...collectRowOperators(expect["rows"], joinPath(expectPath, "rows"), targetName)); @@ -102,20 +103,20 @@ function validateDbTarget(target: Record, path: string): Config const targetName = getTargetName(target); const db = target["db"]; - if (!isRecord(db)) { + if (!isPlainObject(db)) { issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName)); issues.push(...validateDbExpect(target, path)); return issues; } // url 必填 - if (typeof db["url"] !== "string" || db["url"].trim() === "") { + if (!isString(db["url"]) || db["url"].trim() === "") { issues.push(issue("required", joinPath(joinPath(path, "db"), "url"), "缺少 db.url 字段", targetName)); } // query 可选但不能为空字符串 if (db["query"] !== undefined) { - if (typeof db["query"] !== "string") { + if (!isString(db["query"])) { issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName)); } else if (db["query"].trim() === "") { issues.push( diff --git a/src/server/checker/runner/http/body.ts b/src/server/checker/runner/http/body.ts index f994420..1cc2521 100644 --- a/src/server/checker/runner/http/body.ts +++ b/src/server/checker/runner/http/body.ts @@ -1,5 +1,6 @@ import { DOMParser } from "@xmldom/xmldom"; import * as cheerio from "cheerio"; +import { isArray } from "es-toolkit/compat"; import * as xpath from "xpath"; import type { ExpectResult } from "../../expect/types"; @@ -177,7 +178,7 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect } const nodes = xpath.select(path, doc as unknown as Node); - if (!nodes || !Array.isArray(nodes) || nodes.length === 0) { + if (!nodes || !isArray(nodes) || nodes.length === 0) { return { failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`), matched: false, diff --git a/src/server/checker/runner/http/execute.ts b/src/server/checker/runner/http/execute.ts index 5f39f76..f407881 100644 --- a/src/server/checker/runner/http/execute.ts +++ b/src/server/checker/runner/http/execute.ts @@ -1,4 +1,5 @@ import { isError } from "es-toolkit"; +import { isObject } from "es-toolkit/compat"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; @@ -95,7 +96,7 @@ export class HttpChecker implements CheckerDefinition { ), matched: false, statusDetail: null, - targetName: t.name, + targetId: t.id, timestamp, }; } @@ -122,8 +123,9 @@ export class HttpChecker implements CheckerDefinition { method, url: t.http.url, }, + id: t.id, intervalMs: context.defaultIntervalMs, - name: t.name, + name: t.name ?? t.id, timeoutMs: context.defaultTimeoutMs, type: "http", } satisfies ResolvedHttpTarget; @@ -154,10 +156,7 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin const method = init.method?.toUpperCase(); if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) { - const headers = - typeof init.headers === "object" && init.headers !== null - ? { ...(init.headers as Record) } - : undefined; + const headers = isObject(init.headers) ? { ...(init.headers as Record) } : undefined; if (headers) { for (const key of Object.keys(headers)) { const lower = key.toLowerCase(); @@ -172,7 +171,7 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin try { const fromOrigin = new URL(fromUrl).origin; const toOrigin = new URL(toUrl).origin; - if (fromOrigin !== toOrigin && newInit.headers && typeof newInit.headers === "object") { + if (fromOrigin !== toOrigin && isObject(newInit.headers)) { const headers = { ...(newInit.headers as Record) }; for (const key of Object.keys(headers)) { if (SENSITIVE_HEADERS.has(key.toLowerCase())) { @@ -264,7 +263,7 @@ function makeResult( failure, matched: failure === null, statusDetail: `HTTP ${statusCode}`, - targetName: t.name, + targetId: t.id, timestamp, }; } diff --git a/src/server/checker/runner/http/expect.ts b/src/server/checker/runner/http/expect.ts index 6ac39c0..84ab4de 100644 --- a/src/server/checker/runner/http/expect.ts +++ b/src/server/checker/runner/http/expect.ts @@ -1,3 +1,5 @@ +import { isNumber, isString } from "es-toolkit"; + import type { ExpectResult } from "../../expect/types"; import type { HeaderExpect } from "./types"; @@ -14,7 +16,7 @@ export function checkHeaders( const actualValue = headers[key.toLowerCase()]; const path = `headers.${key}`; - if (typeof expected === "string") { + if (isString(expected)) { if (actualValue !== expected) { return { failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`), @@ -45,7 +47,7 @@ export function checkHeaders( export function checkStatus(statusCode: number, allowed: Array): ExpectResult { const matched = allowed.some((pattern) => { - if (typeof pattern === "number") return statusCode === pattern; + if (isNumber(pattern)) return statusCode === pattern; const base = parseInt(pattern[0]!, 10) * 100; return statusCode >= base && statusCode < base + 100; }); diff --git a/src/server/checker/runner/http/validate.ts b/src/server/checker/runner/http/validate.ts index 051dcb5..c829100 100644 --- a/src/server/checker/runner/http/validate.ts +++ b/src/server/checker/runner/http/validate.ts @@ -1,4 +1,6 @@ import { DOMParser } from "@xmldom/xmldom"; +import { isNumber, isString } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; import * as xpath from "xpath"; import type { ConfigValidationIssue } from "../../schema/issues"; @@ -14,7 +16,7 @@ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); const OPERATOR_KEY_SET = new Set(OperatorKeys); export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] { - if (!Array.isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)]; + if (!isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)]; return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName)); } @@ -75,24 +77,25 @@ function collectOperatorObject( } function getTargetName(target: Record): string | undefined { - return typeof target["name"] === "string" ? target["name"] : undefined; + if (isString(target["name"])) return target["name"]; + return isString(target["id"]) ? target["id"] : undefined; } function isNonNegativeFiniteNumber(value: unknown): boolean { - return typeof value === "number" && Number.isFinite(value) && value >= 0; + return isNumber(value) && Number.isFinite(value) && value >= 0; } function isSizeInput(value: unknown): value is number | string { - return typeof value === "number" || typeof value === "string"; + return isNumber(value) || isString(value); } function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; - if (typeof rule["selector"] !== "string" || rule["selector"].trim() === "") { + if (!isString(rule["selector"]) || rule["selector"].trim() === "") { issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName)); } - if ("attr" in rule && typeof rule["attr"] !== "string") { + if ("attr" in rule && !isString(rule["attr"])) { issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName)); } const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName); @@ -112,7 +115,7 @@ function validateHttpExpect(target: Record, path: string): Conf if (isPlainRecord(expect["headers"])) { for (const [key, value] of Object.entries(expect["headers"])) { - if (typeof value === "string") continue; + if (isString(value)) continue; issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName)); } } @@ -121,7 +124,7 @@ function validateHttpExpect(target: Record, path: string): Conf issues.push(...validateBodyRules(expect["body"], joinPath(expectPath, "body"), targetName)); } - if (Array.isArray(expect["status"])) { + if (isArray(expect["status"])) { issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName)); } @@ -141,7 +144,7 @@ function validateHttpTarget(target: Record, path: string): Conf issues.push(...validateHttpExpect(target, path)); return issues; } - if (typeof http["url"] !== "string" || http["url"].trim() === "") { + if (!isString(http["url"]) || http["url"].trim() === "") { issues.push(issue("required", joinPath(joinPath(path, "http"), "url"), "缺少 http.url 字段", targetName)); } else { try { @@ -172,7 +175,7 @@ function validateHttpTarget(target: Record, path: string): Conf function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; - if (typeof rule["path"] !== "string") { + if (!isString(rule["path"])) { issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName)); } else { issues.push(...validateJsonPath(rule["path"], path, targetName)); @@ -186,7 +189,7 @@ function validateJsonRule(rule: unknown, path: string, targetName?: string): Con } function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { - if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)]; + if (!isString(rule)) return [issue("invalid-type", path, "必须为字符串", targetName)]; try { new RegExp(rule); } catch { @@ -210,7 +213,7 @@ function validateSingleBodyRule(rule: unknown, path: string, targetName?: string switch (ruleType) { case "contains": - return typeof rule["contains"] === "string" + return isString(rule["contains"]) ? [] : [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)]; case "css": @@ -238,13 +241,13 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri for (let i = 0; i < values.length; i++) { const value = values[i]; const itemPath = `${path}[${i}]`; - if (typeof value === "number") { + if (isNumber(value)) { if (!Number.isInteger(value) || value < 100 || value > 599) { issues.push(issue("invalid-status", itemPath, "status 数字必须为 100-599 之间的整数", targetName)); } continue; } - if (typeof value === "string") { + if (isString(value)) { if (!/^[1-5]xx$/.test(value)) { issues.push(issue("invalid-status", itemPath, "status 模式必须为 1xx 到 5xx", targetName)); } @@ -258,7 +261,7 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; const issues: ConfigValidationIssue[] = []; - if (typeof rule["path"] !== "string" || rule["path"].trim() === "") { + if (!isString(rule["path"]) || rule["path"].trim() === "") { issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName)); } else { try { diff --git a/src/server/checker/schema/builder.ts b/src/server/checker/schema/builder.ts index f7949f8..537eacf 100644 --- a/src/server/checker/schema/builder.ts +++ b/src/server/checker/schema/builder.ts @@ -4,7 +4,7 @@ import { Type } from "@sinclair/typebox"; import type { CheckerDefinition } from "../runner/types"; -import { durationSchema } from "./fragments"; +import { durationSchema, variableValueSchema } from "./fragments"; export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record { return { @@ -41,6 +41,7 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), { minItems: 1, }), + variables: Type.Optional(Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema)), }, { additionalProperties: false }, ); @@ -50,8 +51,9 @@ export function createTargetSchema(checker: CheckerDefinition): TSchema { const properties: Record = { expect: Type.Optional(checker.schemas.expect), group: Type.Optional(Type.String()), + id: Type.String({ minLength: 1 }), interval: Type.Optional(durationSchema), - name: Type.String({ minLength: 1 }), + name: Type.Optional(Type.String({ minLength: 1 })), timeout: Type.Optional(durationSchema), type: Type.Literal(checker.type), }; @@ -67,8 +69,9 @@ function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema { return Type.Object( { group: Type.Optional(Type.String()), + id: Type.String({ minLength: 1 }), interval: Type.Optional(durationSchema), - name: Type.String({ minLength: 1 }), + name: Type.Optional(Type.String({ 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/schema/fragments.ts b/src/server/checker/schema/fragments.ts index ebde9fa..9d2b172 100644 --- a/src/server/checker/schema/fragments.ts +++ b/src/server/checker/schema/fragments.ts @@ -29,6 +29,8 @@ export const jsonValueSchema = Type.Unsafe({ export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]); +export const variableValueSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean()]); + export const statusCodePatternSchema = Type.Union([ Type.Integer({ maximum: 599, minimum: 100 }), Type.String({ pattern: "^[1-5]xx$" }), diff --git a/src/server/checker/schema/issues.ts b/src/server/checker/schema/issues.ts index 2b56de1..113a638 100644 --- a/src/server/checker/schema/issues.ts +++ b/src/server/checker/schema/issues.ts @@ -2,6 +2,7 @@ export interface ConfigValidationIssue { code: string; message: string; path: string; + targetId?: string; targetName?: string; } @@ -9,8 +10,20 @@ export function formatConfigIssues(issues: ConfigValidationIssue[]): string { return issues.map(formatConfigIssue).join("\n"); } -export function issue(code: string, path: string, message: string, targetName?: string): ConfigValidationIssue { - return targetName === undefined ? { code, message, path } : { code, message, path, targetName }; +export function issue( + code: string, + path: string, + message: string, + targetName?: string, + targetId?: string, +): ConfigValidationIssue { + return { + code, + message, + path, + ...(targetName === undefined ? {} : { targetName }), + ...(targetId === undefined ? {} : { targetId }), + }; } export function joinPath(base: string, key: string): string { @@ -28,6 +41,15 @@ export function throwConfigIssues(issues: ConfigValidationIssue[]): never { } function formatConfigIssue(issue: ConfigValidationIssue): string { + if (issue.targetId) { + const path = issue.path.replace(/^targets\[\d+\]\.?/, ""); + const renderedPath = path === "" ? "配置" : path; + const label = + issue.targetName && issue.targetName !== issue.targetId + ? `target "${issue.targetName}" (id: "${issue.targetId}")` + : `target id "${issue.targetId}"`; + return `${label} 的 ${renderedPath} ${issue.message}`; + } if (issue.targetName) { const path = issue.path.replace(/^targets\[\d+\]\.?/, ""); const renderedPath = path === "" ? "配置" : path; diff --git a/src/server/checker/schema/validate.ts b/src/server/checker/schema/validate.ts index 403a496..ac5a4bc 100644 --- a/src/server/checker/schema/validate.ts +++ b/src/server/checker/schema/validate.ts @@ -1,6 +1,8 @@ import type { ErrorObject } from "ajv"; import Ajv from "ajv"; +import { isPlainObject, isString } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; import type { CheckerRegistry } from "../runner/registry"; import type { ConfigValidationIssue } from "./issues"; @@ -29,12 +31,19 @@ export function validateProbeConfigContract( issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config)); } - if (isRecord(config) && isUnknownArray(config["targets"])) { - const targets = config["targets"]; + if (isPlainObject(config)) { + const configRecord = config as Record; + const targetsValue: unknown = configRecord["targets"]; + if (!isArray(targetsValue)) + return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] }; + const targets = targetsValue; for (let i = 0; i < targets.length; i++) { - const target = targets[i]; - if (!isRecord(target) || typeof target["type"] !== "string") continue; - const checker = registry.tryGet(target["type"]); + const target: unknown = targets[i]; + if (!isPlainObject(target)) continue; + const targetRecord = target as Record; + const targetType: unknown = targetRecord["type"]; + if (!isString(targetType)) continue; + const checker = registry.tryGet(targetType); if (!checker) continue; const targetValidate = ajv.compile(createTargetSchema(checker)); if (!targetValidate(target)) { @@ -62,13 +71,9 @@ function hasMoreSpecificError(keywords: Set): boolean { return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword)); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string): ConfigValidationIssue { const path = buildIssuePath(basePath, error); - const targetName = targetNameFromPath(root, path); + const targetName = targetDisplayNameFromPath(root, path); switch (error.keyword) { case "additionalProperties": return issue("unknown-field", path, "是未知字段", targetName); @@ -91,10 +96,6 @@ function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string): } } -function isUnknownArray(value: unknown): value is unknown[] { - return Array.isArray(value); -} - function joinBasePath(basePath: string, path: string): string { if (basePath === "") return path; if (path === "") return basePath; @@ -136,10 +137,17 @@ function normalizeAjvErrors(errors: ErrorObject[], basePath: string): ErrorObjec }); } -function targetNameFromPath(root: unknown, path: string): string | undefined { +function targetDisplayNameFromPath(root: unknown, path: string): string | undefined { const match = /^targets\[(\d+)\]/.exec(path); - if (!match || !isRecord(root) || !isUnknownArray(root["targets"])) return undefined; - const target = root["targets"][Number(match[1])]; - if (!isRecord(target) || typeof target["name"] !== "string") return undefined; - return target["name"]; + if (!match || !isPlainObject(root)) return undefined; + const rootRecord = root as Record; + const targetsValue: unknown = rootRecord["targets"]; + if (!isArray(targetsValue)) return undefined; + const target: unknown = targetsValue[Number(match[1])]; + if (!isPlainObject(target)) return undefined; + const targetRecord = target as Record; + const targetName: unknown = targetRecord["name"]; + if (isString(targetName)) return targetName; + const targetId: unknown = targetRecord["id"]; + return isString(targetId) ? targetId : undefined; } diff --git a/src/server/checker/store.ts b/src/server/checker/store.ts index 77d050e..999c43f 100644 --- a/src/server/checker/store.ts +++ b/src/server/checker/store.ts @@ -8,8 +8,8 @@ import { checkerRegistry } from "./runner"; const CREATE_TARGETS_TABLE = ` CREATE TABLE IF NOT EXISTS targets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, + id TEXT PRIMARY KEY, + name TEXT NOT NULL, type TEXT NOT NULL, target TEXT NOT NULL, config TEXT NOT NULL DEFAULT '{}', @@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS targets ( const CREATE_CHECK_RESULTS_TABLE = ` CREATE TABLE IF NOT EXISTS check_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, - target_id INTEGER NOT NULL, + target_id TEXT NOT NULL, timestamp TEXT NOT NULL, matched INTEGER NOT NULL, duration_ms REAL, @@ -59,7 +59,7 @@ export class ProbeStore { getAllRecentSamples( limit: number, - ): Map> { + ): Map> { if (this.closed) return new Map(); const rows = this.db @@ -80,11 +80,11 @@ export class ProbeStore { .all(limit) as Array<{ duration_ms: null | number; matched: number; - target_id: number; + target_id: string; timestamp: string; }>; - const result = new Map>(); + const result = new Map>(); for (const row of rows) { const samples = result.get(row.target_id) ?? []; samples.push({ duration_ms: row.duration_ms, matched: row.matched, timestamp: row.timestamp }); @@ -96,7 +96,7 @@ export class ProbeStore { getAllTargetWindowStats( from: string, to: string, - ): Map { + ): Map { if (this.closed) return new Map(); const rows = this.db @@ -108,10 +108,10 @@ export class ProbeStore { WHERE timestamp >= ? AND timestamp <= ? GROUP BY target_id`, ) - .all(from, to) as Array<{ downChecks: number; target_id: number; totalChecks: number; upChecks: number }>; + .all(from, to) as Array<{ downChecks: number; target_id: string; totalChecks: number; upChecks: number }>; const result = new Map< - number, + string, { availability: number; downChecks: number; totalChecks: number; upChecks: number } >(); for (const row of rows) { @@ -129,7 +129,7 @@ export class ProbeStore { getDashboardIncidentStates( from: string, to: string, - ): Array<{ matched: number; target_id: number; timestamp: string }> { + ): Array<{ matched: number; target_id: string; timestamp: string }> { if (this.closed) return []; return this.db @@ -139,11 +139,11 @@ export class ProbeStore { WHERE timestamp >= ? AND timestamp <= ? ORDER BY target_id ASC, timestamp ASC`, ) - .all(from, to) as Array<{ matched: number; target_id: number; timestamp: string }>; + .all(from, to) as Array<{ matched: number; target_id: string; timestamp: string }>; } getHistory( - targetId: number, + targetId: string, from: string, to: string, page = 1, @@ -163,13 +163,13 @@ export class ProbeStore { return { items, page, pageSize, total: countRow.total }; } - getLatestCheck(targetId: number): null | StoredCheckResult { + getLatestCheck(targetId: string): null | StoredCheckResult { return this.db .query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1") .get(targetId) as null | StoredCheckResult; } - getLatestChecksMap(): Map { + getLatestChecksMap(): Map { const rows = this.db .query( `SELECT cr.* FROM check_results cr @@ -184,7 +184,7 @@ export class ProbeStore { } getRecentSamples( - targetId: number, + targetId: string, limit: number, ): Array<{ duration_ms: null | number; matched: number; timestamp: string }> { return this.db @@ -198,13 +198,13 @@ export class ProbeStore { }>; } - getTargetById(id: number): null | StoredTarget { + getTargetById(id: string): null | StoredTarget { if (this.closed) return null; return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget; } getTargetCheckpoints( - targetId: number, + targetId: string, from: string, to: string, ): Array<{ duration_ms: null | number; matched: number; timestamp: string }> { @@ -220,7 +220,7 @@ export class ProbeStore { .all(targetId, from, to) as Array<{ duration_ms: null | number; matched: number; timestamp: string }>; } - getTargetDurations(targetId: number, from: string, to: string): number[] { + getTargetDurations(targetId: string, from: string, to: string): number[] { if (this.closed) return []; const rows = this.db @@ -243,7 +243,7 @@ export class ProbeStore { } getTargetWindowStats( - targetId: number, + targetId: string, from: string, to: string, ): { @@ -281,7 +281,7 @@ export class ProbeStore { failure: CheckFailure | null; matched: boolean; statusDetail: null | string; - targetId: number; + targetId: string; timestamp: string; }): void { if (this.closed) return; @@ -308,18 +308,18 @@ export class ProbeStore { syncTargets(targets: ResolvedTargetBase[]): void { if (this.closed) return; - const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{ - id: number; + const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{ + id: string; name: string; }>; - const existingMap = new Map(existingRows.map((r) => [r.name, r.id])); - const configNames = new Set(targets.map((t) => t.name)); + const existingIds = new Set(existingRows.map((r) => r.id)); + const configIds = new Set(targets.map((t) => t.id)); const insertStmt = this.db.prepare( - "INSERT INTO targets (name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO targets (id, name, type, target, config, interval_ms, timeout_ms, expect, grp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ); const updateStmt = this.db.prepare( - "UPDATE targets SET type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?", + "UPDATE targets SET name = ?, type = ?, target = ?, config = ?, interval_ms = ?, timeout_ms = ?, expect = ?, grp = ? WHERE id = ?", ); const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?"); @@ -331,15 +331,15 @@ export class ProbeStore { const config = serialized.config; const expect = t.expect ? JSON.stringify(t.expect) : null; - if (existingMap.has(t.name)) { - updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, existingMap.get(t.name)!); + if (existingIds.has(t.id)) { + updateStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id); } else { - insertStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group); + insertStmt.run(t.id, t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group); } } - for (const [name, id] of existingMap) { - if (!configNames.has(name)) { + for (const id of existingIds) { + if (!configIds.has(id)) { deleteStmt.run(id); } } diff --git a/src/server/checker/types.ts b/src/server/checker/types.ts index d85eee0..58ab271 100644 --- a/src/server/checker/types.ts +++ b/src/server/checker/types.ts @@ -1,7 +1,7 @@ import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api"; export interface CheckResult extends ApiCheckResult { - targetName: string; + targetId: string; } export interface DefaultsConfig { @@ -36,14 +36,16 @@ export interface ProbeConfig { runtime?: EngineRuntimeConfig; server?: ServerConfig; targets: RawTargetConfig[]; + variables?: Record; } export interface RawTargetConfig { [configKey: string]: unknown; expect?: unknown; group?: string; + id: string; interval?: string; - name: string; + name?: string; timeout?: string; type: string; } @@ -52,6 +54,7 @@ export interface ResolvedTargetBase { [key: string]: unknown; expect?: unknown; group: string; + id: string; intervalMs: number; name: string; timeoutMs: number; @@ -70,7 +73,7 @@ export interface StoredCheckResult { id: number; matched: number; status_detail: null | string; - target_id: number; + target_id: string; timestamp: string; } @@ -78,7 +81,7 @@ export interface StoredTarget { config: string; expect: null | string; grp: string; - id: number; + id: string; interval_ms: number; name: string; target: string; @@ -86,4 +89,6 @@ export interface StoredTarget { type: string; } +export type VariableValue = boolean | number | string; + export type { CheckFailure }; diff --git a/src/server/checker/utils.ts b/src/server/checker/utils.ts index 63c25af..7ddd9f7 100644 --- a/src/server/checker/utils.ts +++ b/src/server/checker/utils.ts @@ -1,3 +1,5 @@ +import { isNumber } from "es-toolkit"; + const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/; const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/; @@ -20,7 +22,7 @@ export function parseDuration(value: string): number { } export function parseSize(value: number | string): number { - if (typeof value === "number") { + if (isNumber(value)) { if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) { throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`); } diff --git a/src/server/checker/variables.ts b/src/server/checker/variables.ts new file mode 100644 index 0000000..c369c33 --- /dev/null +++ b/src/server/checker/variables.ts @@ -0,0 +1,205 @@ +import { isBoolean, isNumber, isPlainObject, isString } from "es-toolkit"; +import { isArray } from "es-toolkit/compat"; + +import type { ConfigValidationIssue } from "./schema/issues"; +import type { VariableValue } from "./types"; + +import { issue, joinPath } from "./schema/issues"; + +const VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +const VARIABLE_REFERENCE_PATTERN = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}/g; +const COMPLETE_VARIABLE_REFERENCE_PATTERN = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([^}]*))?\}$/; +const ESCAPED_VARIABLE_PATTERN = /\$\$\{([^}]*)\}/g; + +interface VariableReference { + defaultValue?: string; + key: string; +} + +interface VariableResolutionIssueContext { + path: string; + targetId?: string; + targetName?: string; +} + +export function extractVariables(config: unknown): { + issues: ConfigValidationIssue[]; + variables: Map; +} { + const issues: ConfigValidationIssue[] = []; + const variables = new Map(); + + if (!isPlainObject(config)) { + return { issues, variables }; + } + const configRecord = config as Record; + if (configRecord["variables"] === undefined) { + return { issues, variables }; + } + + const rawVariables: unknown = configRecord["variables"]; + if (!isPlainObject(rawVariables)) { + issues.push(issue("invalid-type", "variables", "必须为对象")); + return { issues, variables }; + } + + for (const [key, value] of Object.entries(rawVariables as Record)) { + const path = joinPath("variables", key); + if (!VARIABLE_NAME_PATTERN.test(key)) { + issues.push(issue("invalid-format", path, "变量名不符合命名规则")); + continue; + } + if (!isVariableValue(value)) { + issues.push(issue("invalid-type", path, `变量值不允许为 ${describeInvalidVariableValue(value)}`)); + continue; + } + variables.set(key, value); + } + + return { issues, variables }; +} + +export function resolveVariables(config: unknown): { config: unknown; issues: ConfigValidationIssue[] } { + const { issues, variables } = extractVariables(config); + if (!isPlainObject(config)) { + return { config, issues }; + } + const configRecord = config as Record; + const rawTargets: unknown = configRecord["targets"]; + if (!isArray(rawTargets)) { + return { config, issues }; + } + + const targets = rawTargets.map((target, index) => resolveTargetVariables(target, index, variables, issues)); + return { config: { ...config, targets }, issues }; +} + +function describeInvalidVariableValue(value: unknown): string { + if (value === null) return "null"; + if (isArray(value)) return "array"; + return typeof value; +} + +function inferStringValue(value: string): VariableValue { + const numberValue = Number(value); + if (Number.isFinite(numberValue)) return numberValue; + if (value === "true") return true; + if (value === "false") return false; + return value; +} + +function isVariableValue(value: unknown): value is VariableValue { + return isString(value) || isNumber(value) || isBoolean(value); +} + +function parseVariableReference(match: RegExpExecArray): VariableReference { + return { defaultValue: match[2], key: match[1]! }; +} + +function replaceStringValue( + value: string, + variables: Map, + issues: ConfigValidationIssue[], + context: VariableResolutionIssueContext, +): string | VariableValue { + const trimmed = value.trim(); + const completeMatch = COMPLETE_VARIABLE_REFERENCE_PATTERN.exec(trimmed); + if (completeMatch) { + const resolved = resolveVariableReference(parseVariableReference(completeMatch), variables, issues, context); + return resolved ?? value; + } + + const escaped: string[] = []; + const protectedValue = value.replace(ESCAPED_VARIABLE_PATTERN, (_match, body: string) => { + const token = `\u0000${escaped.length}\u0000`; + escaped.push(`\${${body}}`); + return token; + }); + + const replaced = protectedValue.replace( + VARIABLE_REFERENCE_PATTERN, + (match, key: string, defaultValue: string | undefined) => { + const resolved = resolveVariableReference({ defaultValue, key }, variables, issues, context); + return resolved === undefined ? match : String(resolved); + }, + ); + + return escaped.reduce((result, literal, index) => result.replace(`\u0000${index}\u0000`, literal), replaced); +} + +function resolveTargetVariables( + target: unknown, + index: number, + variables: Map, + issues: ConfigValidationIssue[], +): unknown { + if (!isPlainObject(target)) return target; + const targetRecord = target as Record; + const idValue: unknown = targetRecord["id"]; + const nameValue: unknown = targetRecord["name"]; + const targetId = isString(idValue) ? idValue : undefined; + const targetName = isString(nameValue) ? nameValue : targetId; + return resolveValue(target, `targets[${index}]`, variables, issues, { + path: `targets[${index}]`, + targetId, + targetName, + }); +} + +function resolveValue( + value: unknown, + path: string, + variables: Map, + issues: ConfigValidationIssue[], + context: VariableResolutionIssueContext, +): unknown { + if (isString(value)) { + return replaceStringValue(value, variables, issues, { ...context, path }); + } + if (isArray(value)) { + return value.map((item, index) => + resolveValue(item, `${path}[${index}]`, variables, issues, { ...context, path: `${path}[${index}]` }), + ); + } + if (!isPlainObject(value)) return value; + + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + const itemPath = joinPath(path, key); + result[key] = + key === "id" || key === "type" + ? item + : resolveValue(item, itemPath, variables, issues, { ...context, path: itemPath }); + } + return result; +} + +function resolveVariableReference( + reference: VariableReference, + variables: Map, + issues: ConfigValidationIssue[], + context: VariableResolutionIssueContext, +): undefined | VariableValue { + if (variables.has(reference.key)) { + return variables.get(reference.key); + } + + if (Object.prototype.hasOwnProperty.call(process.env, reference.key)) { + return inferStringValue(process.env[reference.key] ?? ""); + } + + if (reference.defaultValue !== undefined) { + return inferStringValue(reference.defaultValue); + } + + issues.push( + issue( + "unresolved-variable", + context.path, + `引用了未定义的变量 "${reference.key}",且环境变量中也不存在,未设置默认值`, + context.targetName, + context.targetId, + ), + ); + return undefined; +} diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 51b9bbb..995886e 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -69,12 +69,11 @@ export function validateRecentLimit(limitParam: null | string, mode: RuntimeMode return { recentLimit }; } -export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: number } { - const id = Number(idStr); - if (!Number.isInteger(id) || id <= 0) { +export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: string } { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) { return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 }); } - return { id }; + return { id: idStr }; } export function validateTimeRange( diff --git a/src/server/routes/dashboard.ts b/src/server/routes/dashboard.ts index 3d9e3e8..9483c0b 100644 --- a/src/server/routes/dashboard.ts +++ b/src/server/routes/dashboard.ts @@ -88,9 +88,9 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode): } function groupDashboardIncidentStates( - states: Array<{ matched: number; target_id: number; timestamp: string }>, -): Map { - const result = new Map(); + states: Array<{ matched: number; target_id: string; timestamp: string }>, +): Map { + const result = new Map(); for (const state of states) { const list = result.get(state.target_id) ?? []; list.push({ durationMs: null, matched: state.matched === 1, timestamp: state.timestamp }); diff --git a/src/shared/api.ts b/src/shared/api.ts index 190eed6..898b18a 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -81,7 +81,7 @@ export interface TargetMetricsResponse { totalChecks: number; upChecks: number; }; - targetId: number; + targetId: string; trend: TrendPoint[]; window: { bucket: "1h"; @@ -100,7 +100,7 @@ export interface TargetStats { export interface TargetStatus { currentStreak: CurrentStreak | null; group: string; - id: number; + id: string; interval: string; latestCheck: CheckResult | null; name: string; diff --git a/src/web/hooks/use-queries.ts b/src/web/hooks/use-queries.ts index c7b29b4..c622f61 100644 --- a/src/web/hooks/use-queries.ts +++ b/src/web/hooks/use-queries.ts @@ -5,7 +5,7 @@ import type { DashboardResponse, MetaResponse, TargetMetricsResponse } from "../ const queryKeys = { dashboard: () => ["dashboard", "24h", 30] as const, meta: () => ["meta"] as const, - metrics: (targetId: number, from: string, to: string, bucket: "1h") => + metrics: (targetId: string, from: string, to: string, bucket: "1h") => ["metrics", targetId, from, to, bucket] as const, }; @@ -32,7 +32,7 @@ export function useMeta() { }); } -export function useTargetMetrics(targetId: null | number, from: string, to: string, bucket: "1h") { +export function useTargetMetrics(targetId: null | string, from: string, to: string, bucket: "1h") { return useQuery({ enabled: targetId !== null && !!from && !!to, queryFn: () => { diff --git a/src/web/hooks/use-target-detail.ts b/src/web/hooks/use-target-detail.ts index 7072fac..a52f4f5 100644 --- a/src/web/hooks/use-target-detail.ts +++ b/src/web/hooks/use-target-detail.ts @@ -7,11 +7,11 @@ import { subtractHours } from "../utils/time"; import { fetchJson, useDashboard, useTargetMetrics } from "./use-queries"; const detailQueryKeys = { - history: (targetId: number, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const, + history: (targetId: string, from: string, to: string, page: number) => ["history", targetId, from, to, page] as const, }; export function useTargetDetail() { - const [selectedTargetId, setSelectedTargetId] = useState(null); + const [selectedTargetId, setSelectedTargetId] = useState(null); const [timeFrom, setTimeFrom] = useState(""); const [timeTo, setTimeTo] = useState(""); const [historyPage, setHistoryPage] = useState(1); diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index f25d21f..50ae5e5 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -50,6 +50,7 @@ describe("API 路由", () => { method: "GET", url: "http://a.com", }, + id: "test-a", intervalMs: 30000, name: "test-a", timeoutMs: 10000, @@ -64,6 +65,7 @@ describe("API 路由", () => { maxOutputBytes: 104857600, }, group: "default", + id: "test-b", intervalMs: 60000, name: "test-b", timeoutMs: 5000, @@ -204,6 +206,7 @@ describe("API 路由", () => { expect(body.targets).toHaveLength(2); const tA = body.targets.find((t) => t.name === "test-a")!; + expect(tA.id).toBe("test-a"); expect(tA.type).toBe("http"); expect(tA.target).toBe("http://a.com"); expect(tA.group).toBe("default"); @@ -372,9 +375,9 @@ describe("API 路由", () => { expect(body["error"]).toContain("from and to"); }); - test("metrics 无效 targetId 返回 400", async () => { + test("metrics 无效 target id 返回 400", async () => { const response = await fetch( - `${baseUrl}/api/targets/invalid/metrics?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`, + `${baseUrl}/api/targets/_invalid/metrics?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`, ); const body = (await response.json()) as Record; diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts index cbd2397..d53b8f5 100644 --- a/tests/server/bootstrap.test.ts +++ b/tests/server/bootstrap.test.ts @@ -12,6 +12,7 @@ type ShutdownSignal = "SIGINT" | "SIGTERM"; const target: ResolvedTargetBase = { group: "default", + id: "test", intervalMs: 30000, name: "test", timeoutMs: 5000, diff --git a/tests/server/checker/config-contract/validate.test.ts b/tests/server/checker/config-contract/validate.test.ts index 99ebfdd..4aafd80 100644 --- a/tests/server/checker/config-contract/validate.test.ts +++ b/tests/server/checker/config-contract/validate.test.ts @@ -28,6 +28,7 @@ describe("config contract", () => { targets: [ { http: { method: "get", unknownHttpField: true, url: "https://example.com" }, + id: "api", name: "api", type: "http", }, @@ -36,6 +37,31 @@ describe("config contract", () => { ).toBe(false); }); + test("导出 schema 支持 variables 且要求 target id", () => { + const ajv = new Ajv({ + allErrors: true, + coerceTypes: false, + removeAdditional: false, + strict: true, + useDefaults: false, + }); + const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry())); + + expect( + validate({ + targets: [{ http: { url: "https://example.com" }, id: "api", type: "http" }], + variables: { base_url: "https://example.com", enabled: true, port: 443 }, + }), + ).toBe(true); + + expect( + validate({ + targets: [{ http: { url: "https://example.com" }, type: "http" }], + variables: { bad: null }, + }), + ).toBe(false); + }); + test("Ajv 错误转换为中文结构化 issue", () => { const result = validateProbeConfigContract( { @@ -43,6 +69,7 @@ describe("config contract", () => { { group: 123, http: { extra: true }, + id: "api", name: "api", type: "http", }, diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index b5d140c..7e63b38 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -107,6 +107,7 @@ describe("loadConfig", () => { configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -121,6 +122,7 @@ describe("loadConfig", () => { expect(config.targets).toHaveLength(1); const t = config.targets[0]! as ResolvedHttpTarget; expect(t.type).toBe("http"); + expect(t.id).toBe("test"); expect(t.name).toBe("test"); expect(t.http.url).toBe("http://example.com"); expect(t.http.method).toBe("GET"); @@ -140,6 +142,7 @@ describe("loadConfig", () => { configPath, `targets: - name: "check-nginx" + id: "check-nginx" type: cmd cmd: exec: "pgrep" @@ -151,6 +154,7 @@ describe("loadConfig", () => { expect(config.targets).toHaveLength(1); const t = config.targets[0]! as ResolvedCommandTarget; expect(t.type).toBe("cmd"); + expect(t.id).toBe("check-nginx"); expect(t.name).toBe("check-nginx"); expect(t.cmd.exec).toBe("pgrep"); expect(t.cmd.args).toEqual(["nginx"]); @@ -181,6 +185,7 @@ defaults: maxOutputBytes: "10MB" targets: - name: "http-target" + id: "http-target" type: http interval: "1m" http: @@ -193,6 +198,7 @@ targets: body: - contains: "ok" - name: "cmd-target" + id: "cmd-target" type: cmd cmd: exec: "ls" @@ -228,6 +234,140 @@ targets: expect(cmd.cmd.maxOutputBytes).toBe(10485760); }); + test("name 缺省时 fallback 到 id", async () => { + const configPath = join(tempDir, "name-fallback.yaml"); + await writeFile( + configPath, + `targets: + - id: "api-health" + type: http + http: + url: "http://example.com" +`, + ); + + const config = await loadConfig(configPath); + const target = config.targets[0]!; + expect(target.id).toBe("api-health"); + expect(target.name).toBe("api-health"); + }); + + test("name 支持变量替换且不要求唯一", async () => { + const configPath = join(tempDir, "name-variable.yaml"); + await writeFile( + configPath, + `variables: + env: "生产" +targets: + - id: "api-a" + name: "\${env} API" + type: http + http: + url: "http://a.example.com" + - id: "api-b" + name: "\${env} API" + type: http + http: + url: "http://b.example.com" +`, + ); + + const config = await loadConfig(configPath); + expect(config.targets.map((target) => [target.id, target.name])).toEqual([ + ["api-a", "生产 API"], + ["api-b", "生产 API"], + ]); + }); + + test("包含 variables 的完整配置在 schema 校验前完成替换", async () => { + const configPath = join(tempDir, "variables-full.yaml"); + await writeFile( + configPath, + `variables: + env: "生产" + base_url: "https://example.com" + ignore_ssl: true + max_redirects: 5 +targets: + - id: "api-health" + name: "\${env} API 健康检查" + type: http + http: + url: "\${base_url}/health" + ignoreSSL: "\${ignore_ssl}" + maxRedirects: "\${max_redirects}" +`, + ); + + const config = await loadConfig(configPath); + const target = config.targets[0] as ResolvedHttpTarget; + expect(target.id).toBe("api-health"); + expect(target.name).toBe("生产 API 健康检查"); + expect(target.http.url).toBe("https://example.com/health"); + expect(target.http.ignoreSSL).toBe(true); + expect(target.http.maxRedirects).toBe(5); + }); + + test("变量替换后类型不匹配导致 schema 校验失败", async () => { + const configPath = join(tempDir, "bad-var-type.yaml"); + await writeFile( + configPath, + `variables: + max_redirects: "not-a-number" +targets: + - id: "bad-var" + type: http + http: + url: "http://example.com" + maxRedirects: "\${max_redirects}" +`, + ); + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects"); + }); + + test("变量替换后通过 schema 校验", async () => { + const configPath = join(tempDir, "good-var-type.yaml"); + const origEnv = process.env["DIAL_VAR_MAX_REDIRECTS"]; + process.env["DIAL_VAR_MAX_REDIRECTS"] = "3"; + try { + await writeFile( + configPath, + `targets: + - id: "good-var" + type: http + http: + url: "http://example.com" + maxRedirects: "\${DIAL_VAR_MAX_REDIRECTS}" +`, + ); + const config = await loadConfig(configPath); + const target = config.targets[0] as ResolvedHttpTarget; + expect(target.http.maxRedirects).toBe(3); + } finally { + if (origEnv === undefined) { + delete process.env["DIAL_VAR_MAX_REDIRECTS"]; + } else { + process.env["DIAL_VAR_MAX_REDIRECTS"] = origEnv; + } + } + }); + + test("未定义变量且无默认值阻止启动", async () => { + const configPath = join(tempDir, "unresolved-var.yaml"); + await writeFile( + configPath, + `targets: + - id: "unresolved" + type: http + http: + url: "\${MISSING_BASE_URL}/health" +`, + ); + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(loadConfig(configPath)).rejects.toThrow("未定义的变量"); + }); + test("绝对 dataDir 保持不变", async () => { const dataDir = join(tempDir, "absolute-data"); const configPath = join(tempDir, "absolute-data-dir.yaml"); @@ -237,6 +377,7 @@ targets: dataDir: ${JSON.stringify(dataDir)} targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -258,6 +399,7 @@ targets: maxBodyBytes: "10MB" targets: - name: "override-all" + id: "override-all" type: http interval: "5m" timeout: "30s" @@ -281,8 +423,8 @@ targets: await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在"); }); - test("target 缺少 name 抛出错误", async () => { - const configPath = join(tempDir, "no-name.yaml"); + test("target 缺少 id 抛出错误", async () => { + const configPath = join(tempDir, "no-id.yaml"); await writeFile( configPath, `targets: @@ -292,7 +434,7 @@ targets: `, ); // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段"); + await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段"); }); test("target 缺少 type 抛出错误", async () => { @@ -301,6 +443,7 @@ targets: configPath, `targets: - name: "test" + id: "test" http: url: "http://example.com" `, @@ -315,6 +458,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: {} `, @@ -329,6 +473,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http `, ); @@ -342,6 +487,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -358,6 +504,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -374,6 +521,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -391,6 +539,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: cmd cmd: {} `, @@ -405,6 +554,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: dns `, ); @@ -412,23 +562,70 @@ targets: await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type"); }); - test("target name 重复抛出错误", async () => { - const configPath = join(tempDir, "dup-name.yaml"); + test("target id 重复抛出错误", async () => { + const configPath = join(tempDir, "dup-id.yaml"); await writeFile( configPath, `targets: - name: "dup" + id: "dup" type: http http: url: "http://a.com" - name: "dup" + id: "dup" type: http http: url: "http://b.com" `, ); // eslint-disable-next-line @typescript-eslint/await-thenable - await expect(loadConfig(configPath)).rejects.toThrow("target name 重复"); + await expect(loadConfig(configPath)).rejects.toThrow("target id 重复"); + }); + + test("target id 为空字符串抛出错误", async () => { + const configPath = join(tempDir, "empty-id.yaml"); + await writeFile( + configPath, + `targets: + - id: "" + type: http + http: + url: "http://example.com" +`, + ); + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段"); + }); + + test("target id 命名不合法抛出错误", async () => { + const configPath = join(tempDir, "bad-id.yaml"); + await writeFile( + configPath, + `targets: + - id: "_invalid" + type: http + http: + url: "http://example.com" +`, + ); + // eslint-disable-next-line @typescript-eslint/await-thenable + await expect(loadConfig(configPath)).rejects.toThrow("id 不符合命名规则"); + }); + + test("target id 包含下划线和连字符通过", async () => { + const configPath = join(tempDir, "id-underscore-dash.yaml"); + await writeFile( + configPath, + `targets: + - id: "db_check-01" + type: http + http: + url: "http://example.com" +`, + ); + const config = await loadConfig(configPath); + expect(config.targets[0]!.id).toBe("db_check-01"); }); test("targets 为空数组抛出错误", async () => { @@ -446,6 +643,7 @@ targets: port: 99999 targets: - name: "t" + id: "t" type: http http: url: "http://a.com" @@ -463,6 +661,7 @@ targets: maxConcurrentChecks: -1 targets: - name: "t" + id: "t" type: http http: url: "http://a.com" @@ -481,6 +680,7 @@ targets: maxBodyBytes: "100TB" targets: - name: "t" + id: "t" type: http http: url: "http://a.com" @@ -496,6 +696,7 @@ targets: configPath, `targets: - name: "t" + id: "t" type: http interval: "30x" http: @@ -512,6 +713,7 @@ targets: configPath, `targets: - name: "with-expect" + id: "with-expect" type: http http: url: "http://example.com" @@ -543,6 +745,7 @@ targets: configPath, `targets: - name: "cmd-with-expect" + id: "cmd-with-expect" type: cmd cmd: exec: "mycheck" @@ -577,6 +780,7 @@ targets: configPath, `targets: - name: "cwd-test" + id: "cwd-test" type: cmd cmd: exec: "ls" @@ -595,6 +799,7 @@ targets: configPath, `targets: - name: "env-test" + id: "env-test" type: cmd cmd: exec: "echo" @@ -617,6 +822,7 @@ targets: configPath, `targets: - name: "grouped" + id: "grouped" type: http group: "搜索引擎" http: @@ -634,6 +840,7 @@ targets: configPath, `targets: - name: "no-group" + id: "no-group" type: http http: url: "http://example.com" @@ -650,6 +857,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http group: 123 http: @@ -667,6 +875,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -684,6 +893,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -700,6 +910,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -716,6 +927,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -732,6 +944,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -749,6 +962,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -766,6 +980,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -783,6 +998,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -800,6 +1016,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -818,6 +1035,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -836,6 +1054,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -855,6 +1074,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -873,6 +1093,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -893,6 +1114,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -912,6 +1134,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -931,6 +1154,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -951,6 +1175,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -971,6 +1196,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -993,6 +1219,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -1012,6 +1239,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -1028,6 +1256,7 @@ targets: "lowercase-method.yaml", `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -1045,6 +1274,7 @@ targets: method: POST targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -1059,6 +1289,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -1081,6 +1312,7 @@ targets: X-Default-Header: "default" targets: - name: "http-test" + id: "http-test" type: http http: url: "http://example.com" @@ -1091,6 +1323,7 @@ targets: X-Response-Header: contains: "ok" - name: "cmd-test" + id: "cmd-test" type: cmd cmd: exec: "true" @@ -1113,6 +1346,7 @@ targets: "bad-cmd-args.yaml", `targets: - name: "cmd" + id: "cmd" type: cmd cmd: exec: "echo" @@ -1127,6 +1361,7 @@ targets: "bad-cmd-cwd.yaml", `targets: - name: "cmd" + id: "cmd" type: cmd cmd: exec: "echo" @@ -1141,6 +1376,7 @@ targets: "bad-cmd-env.yaml", `targets: - name: "cmd" + id: "cmd" type: cmd cmd: exec: "echo" @@ -1156,6 +1392,7 @@ targets: "bad-cmd-max-output.yaml", `targets: - name: "cmd" + id: "cmd" type: cmd cmd: exec: "echo" @@ -1170,6 +1407,7 @@ targets: "bad-cmd-exit-code.yaml", `targets: - name: "cmd" + id: "cmd" type: cmd cmd: exec: "echo" @@ -1185,6 +1423,7 @@ targets: "bad-cmd-stdout-empty.yaml", `targets: - name: "cmd" + id: "cmd" type: cmd cmd: exec: "echo" @@ -1201,6 +1440,7 @@ targets: "bad-cmd-stderr-operator.yaml", `targets: - name: "cmd" + id: "cmd" type: cmd cmd: exec: "echo" @@ -1217,6 +1457,7 @@ targets: "bad-cmd-stdout-regex.yaml", `targets: - name: "cmd" + id: "cmd" type: cmd cmd: exec: "echo" @@ -1233,6 +1474,7 @@ targets: "bad-cmd-expect-unknown.yaml", `targets: - name: "cmd" + id: "cmd" type: cmd cmd: exec: "echo" @@ -1249,6 +1491,7 @@ targets: configPath, `targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -1266,6 +1509,7 @@ targets: retention: "24h" targets: - name: "test" + id: "test" type: http http: url: "http://example.com" @@ -1282,6 +1526,7 @@ targets: retention: "7x" targets: - name: "test" + id: "test" type: http http: url: "http://example.com" diff --git a/tests/server/checker/engine.test.ts b/tests/server/checker/engine.test.ts index 62e2f61..c05076e 100644 --- a/tests/server/checker/engine.test.ts +++ b/tests/server/checker/engine.test.ts @@ -15,8 +15,7 @@ const processEnv = Object.fromEntries( ); function createMockStore(targetNames: string[]) { - let nextId = 1; - const targets = targetNames.map((name) => ({ id: nextId++, name })); + const targets = targetNames.map((name) => ({ id: name, name })); const results: Array> = []; return { @@ -57,6 +56,7 @@ function makeCommandTarget(name: string, overrides?: Partial { const results = (mockStore as unknown as { _results: Array> })._results; expect(results.length).toBe(2); - expect(results[0]!["targetId"]).toBe(1); + expect(results[0]!["targetId"]).toBe("reject-cmd"); expect(results[0]!["matched"]).toBe(false); expect(results[0]!["durationMs"]).toBeNull(); expect(results[0]!["statusDetail"]).toBeNull(); @@ -188,7 +188,7 @@ describe("ProbeEngine", () => { phase: "internal", }); expect(typeof results[0]!["timestamp"]).toBe("string"); - expect(results[1]!["targetId"]).toBe(2); + expect(results[1]!["targetId"]).toBe("good-cmd"); expect(results[1]!["matched"]).toBe(true); } finally { checker.execute = originalExecute; @@ -235,7 +235,7 @@ describe("ProbeEngine", () => { expect(true).toBe(true); }); - test("未注册的 targetName 不写入结果", async () => { + test("未注册的 target id 不写入结果", async () => { const target = makeCommandTarget("unknown-target"); const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore; const engine = new ProbeEngine(mockStore, [target]); @@ -268,6 +268,7 @@ describe("ProbeEngine", () => { method: "GET", url: `http://localhost:${httpServer.port}/`, }, + id: "http-test", intervalMs: 60000, name: "http-test", timeoutMs: 5000, diff --git a/tests/server/checker/runner/cmd/runner.test.ts b/tests/server/checker/runner/cmd/runner.test.ts index c3e5ac2..8af4d07 100644 --- a/tests/server/checker/runner/cmd/runner.test.ts +++ b/tests/server/checker/runner/cmd/runner.test.ts @@ -31,6 +31,7 @@ function makeTarget( ...cmd, }, group: "default", + id: "test-cmd", intervalMs: 60000, name: "test-cmd", timeoutMs: 5000, diff --git a/tests/server/checker/runner/db/execute.test.ts b/tests/server/checker/runner/db/execute.test.ts index 868189d..9f32d28 100644 --- a/tests/server/checker/runner/db/execute.test.ts +++ b/tests/server/checker/runner/db/execute.test.ts @@ -20,6 +20,7 @@ function makeTarget(db: Partial, overrides?: Partial { test("缺少 db.url 返回错误", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ name: "test", type: "db" }], + targets: [{ id: "test", name: "test", type: "db" }], }); expect(result.length).toBeGreaterThan(0); const dbError = result.find((e) => e.path.includes("db")); @@ -22,7 +22,7 @@ describe("validateDbConfig", () => { test("db.url 为空字符串返回错误", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ db: { url: "" }, name: "test", type: "db" }], + targets: [{ db: { url: "" }, id: "test", name: "test", type: "db" }], }); const urlError = result.find((e) => e.path.includes("db.url")); expect(urlError).toBeDefined(); @@ -32,7 +32,7 @@ describe("validateDbConfig", () => { test("db.query 为空字符串返回错误", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ db: { query: "", url: "sqlite://:memory:" }, name: "test", type: "db" }], + targets: [{ db: { query: "", url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }], }); const queryError = result.find((e) => e.path.includes("db.query")); expect(queryError).toBeDefined(); @@ -42,7 +42,7 @@ describe("validateDbConfig", () => { test("db 分组未知字段返回错误", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ db: { timeout: 5, url: "sqlite://:memory:" }, name: "test", type: "db" }], + targets: [{ db: { timeout: 5, url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }], }); const unknownError = result.find((e) => e.path.includes("db.timeout")); expect(unknownError).toBeDefined(); @@ -52,7 +52,15 @@ describe("validateDbConfig", () => { test("expect.maxDurationMs 非数字返回错误", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ db: { url: "sqlite://:memory:" }, expect: { maxDurationMs: "invalid" }, name: "test", type: "db" }], + targets: [ + { + db: { url: "sqlite://:memory:" }, + expect: { maxDurationMs: "invalid" }, + id: "test", + name: "test", + type: "db", + }, + ], }); const durationError = result.find((e) => e.path.includes("expect.maxDurationMs")); expect(durationError).toBeDefined(); @@ -62,7 +70,9 @@ describe("validateDbConfig", () => { test("expect.rowCount 非法 operator 返回错误", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ db: { url: "sqlite://:memory:" }, expect: { rowCount: { foo: 1 } }, name: "test", type: "db" }], + targets: [ + { db: { url: "sqlite://:memory:" }, expect: { rowCount: { foo: 1 } }, id: "test", name: "test", type: "db" }, + ], }); const rowCountError = result.find((e) => e.path.includes("expect.rowCount")); expect(rowCountError).toBeDefined(); @@ -72,7 +82,9 @@ describe("validateDbConfig", () => { test("expect.rows 不是数组返回错误", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ db: { url: "sqlite://:memory:" }, expect: { rows: "not-array" }, name: "test", type: "db" }], + targets: [ + { db: { url: "sqlite://:memory:" }, expect: { rows: "not-array" }, id: "test", name: "test", type: "db" }, + ], }); const rowsError = result.find((e) => e.path.includes("expect.rows")); expect(rowsError).toBeDefined(); @@ -82,7 +94,9 @@ describe("validateDbConfig", () => { test("expect.rows 元素不是对象返回错误", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ db: { url: "sqlite://:memory:" }, expect: { rows: ["not-object"] }, name: "test", type: "db" }], + targets: [ + { db: { url: "sqlite://:memory:" }, expect: { rows: ["not-object"] }, id: "test", name: "test", type: "db" }, + ], }); const rowError = result.find((e) => e.path.includes("expect.rows[0]")); expect(rowError).toBeDefined(); @@ -96,6 +110,7 @@ describe("validateDbConfig", () => { { db: { url: "sqlite://:memory:" }, expect: { rows: [{ name: { match: "[invalid" } }] }, + id: "test", name: "test", type: "db", }, @@ -109,7 +124,7 @@ describe("validateDbConfig", () => { test("expect 未知字段返回错误", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ db: { url: "sqlite://:memory:" }, expect: { status: [200] }, name: "test", type: "db" }], + targets: [{ db: { url: "sqlite://:memory:" }, expect: { status: [200] }, id: "test", name: "test", type: "db" }], }); const unknownError = result.find((e) => e.path.includes("expect.status")); expect(unknownError).toBeDefined(); @@ -123,6 +138,7 @@ describe("validateDbConfig", () => { { db: { query: "SELECT 1", url: "sqlite://:memory:" }, expect: { maxDurationMs: 5000, rowCount: { gte: 1 }, rows: [{ cnt: { gte: 1 } }] }, + id: "test", name: "test", type: "db", }, @@ -134,7 +150,7 @@ describe("validateDbConfig", () => { test("忽略非 db 类型 target", () => { const result = validateDbConfig({ defaults: {}, - targets: [{ name: "test", type: "http" }], + targets: [{ id: "test", name: "test", type: "http" }], }); expect(result).toHaveLength(0); }); @@ -143,8 +159,8 @@ describe("validateDbConfig", () => { const result = validateDbConfig({ defaults: {}, targets: [ - { db: { url: "sqlite://:memory:" }, name: "db1", type: "db" }, - { db: { url: "" }, name: "db2", type: "db" }, + { db: { url: "sqlite://:memory:" }, id: "db1", name: "db1", type: "db" }, + { db: { url: "" }, id: "db2", name: "db2", type: "db" }, ], }); expect(result.length).toBeGreaterThan(0); diff --git a/tests/server/checker/runner/http/runner.test.ts b/tests/server/checker/runner/http/runner.test.ts index 0b0445a..09ca313 100644 --- a/tests/server/checker/runner/http/runner.test.ts +++ b/tests/server/checker/runner/http/runner.test.ts @@ -166,6 +166,7 @@ describe("HttpChecker", () => { method: overrides.method ?? "GET", url: overrides.url ?? `${baseUrl}/ok`, }, + id: "test-http", intervalMs: 60000, name: "test-http", timeoutMs: overrides.timeoutMs ?? 5000, @@ -850,6 +851,7 @@ describe("HttpChecker.resolve", () => { const errors = validateHttpTarget({ expect: { status: ["abc"] }, http: { url: "https://example.com" }, + id: "test", name: "test", type: "http", }); @@ -858,7 +860,7 @@ describe("HttpChecker.resolve", () => { test("ignoreSSL 默认值为 false", () => { const result = checker.resolve( - { http: { url: "https://example.com" }, name: "test", type: "http" }, + { http: { url: "https://example.com" }, id: "test", name: "test", type: "http" }, makeResolveContext(), ); expect(result.http.ignoreSSL).toBe(false); @@ -866,7 +868,7 @@ describe("HttpChecker.resolve", () => { test("maxRedirects 默认值为 0", () => { const result = checker.resolve( - { http: { url: "https://example.com" }, name: "test", type: "http" }, + { http: { url: "https://example.com" }, id: "test", name: "test", type: "http" }, makeResolveContext(), ); expect(result.http.maxRedirects).toBe(0); @@ -874,7 +876,13 @@ describe("HttpChecker.resolve", () => { test("合法 status 范围模式通过校验", () => { const result = checker.resolve( - { expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" }, + { + expect: { status: ["2xx", 301] }, + http: { url: "https://example.com" }, + id: "test", + name: "test", + type: "http", + }, makeResolveContext(), ); expect(result.expect?.status).toEqual(["2xx", 301]); @@ -882,7 +890,12 @@ describe("HttpChecker.resolve", () => { test("显式 ignoreSSL 和 maxRedirects 正确解析", () => { const result = checker.resolve( - { http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, name: "test", type: "http" }, + { + http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, + id: "test", + name: "test", + type: "http", + }, makeResolveContext(), ); expect(result.http.ignoreSSL).toBe(true); diff --git a/tests/server/checker/store.test.ts b/tests/server/checker/store.test.ts index f7b45f7..b181526 100644 --- a/tests/server/checker/store.test.ts +++ b/tests/server/checker/store.test.ts @@ -24,6 +24,10 @@ beforeAll(() => { ensureRegistered(); }); +function targetId(store: ProbeStore, name: string): string { + return store.getTargets().find((target) => target.name === name)!.id; +} + const httpTarget: ResolvedHttpTarget = { expect: { maxDurationMs: 3000, status: [200] }, group: "default", @@ -35,6 +39,7 @@ const httpTarget: ResolvedHttpTarget = { method: "GET", url: "https://example.com/health", }, + id: "test-http", intervalMs: 30000, name: "test-http", timeoutMs: 10000, @@ -50,6 +55,7 @@ const commandTarget: ResolvedCommandTarget = { maxOutputBytes: 104857600, }, group: "default", + id: "test-cmd", intervalMs: 60000, name: "test-cmd", timeoutMs: 5000, @@ -79,8 +85,7 @@ describe("ProbeStore", () => { store.syncTargets([httpTarget, commandTarget]); const targets = store.getTargets(); expect(targets).toHaveLength(2); - expect(targets[0]!.name).toBe("test-http"); - expect(targets[1]!.name).toBe("test-cmd"); + expect(targets.map((target) => target.name).sort()).toEqual(["test-cmd", "test-http"]); }); test("http target 字段正确", () => { @@ -144,20 +149,18 @@ describe("ProbeStore", () => { }); test("getTargetById", () => { - const targets = store.getTargets(); - const found = store.getTargetById(targets[0]!.id); + const found = store.getTargetById(targetId(store, "test-http")); expect(found).toBeDefined(); expect(found!.name).toBe("test-http"); }); test("getTargetById 不存在", () => { - expect(store.getTargetById(99999)).toBeNull(); + expect(store.getTargetById("missing-target")).toBeNull(); }); test("写入 check result 并查询", () => { store.syncTargets([httpTarget, commandTarget]); - const targets = store.getTargets(); - const t1Id = targets[0]!.id; + const t1Id = targetId(store, "test-http"); store.insertCheckResult({ durationMs: 150.5, @@ -209,8 +212,7 @@ describe("ProbeStore", () => { }); test("getHistory 默认 limit=20", () => { - const targets = store.getTargets(); - const t1Id = targets[0]!.id; + const t1Id = targetId(store, "test-http"); for (let i = 0; i < 25; i++) { store.insertCheckResult({ @@ -228,8 +230,7 @@ describe("ProbeStore", () => { }); test("getTargetWindowStats 按时间窗口计算基础计数", () => { - const targets = store.getTargets(); - const t1Id = targets[0]!.id; + const t1Id = targetId(store, "test-http"); const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); expect(stats.totalChecks).toBeGreaterThan(0); @@ -239,8 +240,7 @@ describe("ProbeStore", () => { }); test("无记录目标的窗口 stats", () => { - const targets = store.getTargets(); - const t2Id = targets.find((t) => t.name === "test-cmd")!.id; + const t2Id = targetId(store, "test-cmd"); const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); expect(stats.totalChecks).toBe(0); @@ -251,16 +251,14 @@ describe("ProbeStore", () => { test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => { const latestChecksMap = store.getLatestChecksMap(); - const targets = store.getTargets(); - const latest = latestChecksMap.get(targets[0]!.id); + const latest = latestChecksMap.get(targetId(store, "test-http")); expect(latest).toBeDefined(); expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z"); }); test("getTargetCheckpoints 返回窗口内升序检查点", () => { - const targets = store.getTargets(); - const t1Id = targets[0]!.id; + const t1Id = targetId(store, "test-http"); const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); expect(checkpoints).toEqual([ @@ -271,16 +269,14 @@ describe("ProbeStore", () => { }); test("getTargetDurations 返回成功检查耗时升序数组", () => { - const targets = store.getTargets(); - const t1Id = targets[0]!.id; + const t1Id = targetId(store, "test-http"); const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); expect(durations).toEqual([150.5, 300]); }); test("getRecentSamples 返回最近采样数据", () => { - const targets = store.getTargets(); - const t1Id = targets[0]!.id; + const t1Id = targetId(store, "test-http"); const samples = store.getRecentSamples(t1Id, 10); expect(Array.isArray(samples)).toBe(true); @@ -293,15 +289,17 @@ describe("ProbeStore", () => { test("getAllRecentSamples 返回每个 target 的最近采样数据", () => { const sampleStore = new ProbeStore(join(tempDir, "all-samples.db")); - const httpA: ResolvedHttpTarget = { ...httpTarget, name: "sample-http-a" }; + const httpA: ResolvedHttpTarget = { ...httpTarget, id: "sample-http-a", name: "sample-http-a" }; const httpB: ResolvedHttpTarget = { ...httpTarget, http: { ...httpTarget.http, url: "https://example.com/other" }, + id: "sample-http-b", name: "sample-http-b", }; const httpEmpty: ResolvedHttpTarget = { ...httpTarget, http: { ...httpTarget.http, url: "https://example.com/empty" }, + id: "sample-http-empty", name: "sample-http-empty", }; sampleStore.syncTargets([httpA, httpB, httpEmpty]); @@ -360,7 +358,7 @@ describe("ProbeStore", () => { const closedStore = new ProbeStore(join(tempDir, "closed.db")); closedStore.close(); expect(closedStore.getTargets()).toHaveLength(0); - expect(closedStore.getTargetById(1)).toBeNull(); + expect(closedStore.getTargetById("closed-target")).toBeNull(); }); test("删除 target 级联删除 check_results", () => { @@ -375,6 +373,7 @@ describe("ProbeStore", () => { method: "GET", url: "http://cascade.test", }, + id: "cascade-test", intervalMs: 30000, name: "cascade-test", timeoutMs: 10000, @@ -437,6 +436,7 @@ describe("ProbeStore", () => { method: "GET", url: "http://no.records", }, + id: "no-records", intervalMs: 30000, name: "no-records", timeoutMs: 10000, @@ -451,9 +451,8 @@ describe("ProbeStore", () => { }); test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => { - const targets = store.getTargets(); - const t1Id = targets[0]!.id; - const t2Id = targets[1]!.id; + const t1Id = targetId(store, "test-http"); + const t2Id = targetId(store, "test-cmd"); const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z"); expect(stats).toBeInstanceOf(Map); @@ -484,6 +483,7 @@ describe("ProbeStore", () => { method: "GET", url: "http://no.stats", }, + id: "no-stats", intervalMs: 30000, name: "no-stats", timeoutMs: 10000, @@ -499,7 +499,7 @@ describe("ProbeStore", () => { test("getAllTargetWindowStats 与 getTargetWindowStats 的 availability 精度一致", () => { const statsStore = new ProbeStore(join(tempDir, "stats-precision.db")); - const target: ResolvedHttpTarget = { ...httpTarget, name: "stats-precision" }; + const target: ResolvedHttpTarget = { ...httpTarget, id: "stats-precision", name: "stats-precision" }; statsStore.syncTargets([target]); const targetId = statsStore.getTargets()[0]!.id; @@ -534,10 +534,11 @@ describe("ProbeStore", () => { test("getDashboardIncidentStates 返回按 target 和 timestamp 升序排列的状态序列", () => { const incidentStore = new ProbeStore(join(tempDir, "dashboard-incidents.db")); - const httpA: ResolvedHttpTarget = { ...httpTarget, name: "incident-http-a" }; + const httpA: ResolvedHttpTarget = { ...httpTarget, id: "incident-http-a", name: "incident-http-a" }; const httpB: ResolvedHttpTarget = { ...httpTarget, http: { ...httpTarget.http, url: "https://example.com/incident-b" }, + id: "incident-http-b", name: "incident-http-b", }; incidentStore.syncTargets([httpA, httpB]); diff --git a/tests/server/checker/variables.test.ts b/tests/server/checker/variables.test.ts new file mode 100644 index 0000000..a82caef --- /dev/null +++ b/tests/server/checker/variables.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, test } from "bun:test"; + +import { extractVariables, resolveVariables } from "../../../src/server/checker/variables"; + +describe("config variables", () => { + test("提取合法 variables 类型", () => { + const result = extractVariables({ variables: { enabled: true, host: "example.com", port: 5432 } }); + + expect(result.issues).toHaveLength(0); + expect(Object.fromEntries(result.variables)).toEqual({ enabled: true, host: "example.com", port: 5432 }); + }); + + test("拒绝非法 variables value 和 key", () => { + const result = extractVariables({ + variables: { + "123start": "value", + empty: null, + list: [1, 2, 3], + obj: { a: 1 }, + }, + }); + + expect(result.issues.map((item) => [item.code, item.path, item.message])).toEqual([ + ["invalid-format", "variables.123start", "变量名不符合命名规则"], + ["invalid-type", "variables.empty", "变量值不允许为 null"], + ["invalid-type", "variables.list", "变量值不允许为 array"], + ["invalid-type", "variables.obj", "变量值不允许为 object"], + ]); + }); + + test("解析简单引用、默认值、转义、多变量拼接和无引用字符串", () => { + const result = resolveVariables({ + targets: [ + { + http: { + body: "Hello $${name}", + headers: { + Authorization: "Bearer ${token}", + Pattern: "${PATTERN|foo|bar}", + }, + url: "${protocol}://${host}:${port}/api", + }, + id: "api-health", + name: "API", + type: "http", + }, + ], + variables: { host: "example.com", port: 443, protocol: "https", token: "abc" }, + }); + + expect(result.issues).toHaveLength(0); + const target = ( + result.config as { targets: Array<{ http: { body: string; headers: Record; url: string } }> } + ).targets[0]!; + expect(target.http.url).toBe("https://example.com:443/api"); + expect(target.http.headers["Authorization"]).toBe("Bearer abc"); + expect(target.http.headers["Pattern"]).toBe("foo|bar"); + expect(target.http.body).toBe("Hello ${name}"); + }); + + test("完整引用保留类型,部分引用强制为字符串", () => { + const result = resolveVariables({ + targets: [ + { + http: { + body: "port: ${port}", + ignoreSSL: "${ssl}", + maxRedirects: "${port}", + url: "${host}", + }, + id: "typed-http", + type: "http", + }, + ], + variables: { host: "https://example.com", port: 5, ssl: true }, + }); + + expect(result.issues).toHaveLength(0); + const http = (result.config as { targets: Array<{ http: Record }> }).targets[0]!.http; + expect(http["url"]).toBe("https://example.com"); + expect(http["maxRedirects"]).toBe(5); + expect(http["ignoreSSL"]).toBe(true); + expect(http["body"]).toBe("port: 5"); + }); + + test("环境变量和默认值在完整引用时做类型推断", () => { + const originalMaxRedirects = process.env["MAX_REDIRECTS"]; + const originalIgnoreSsl = process.env["IGNORE_SSL"]; + process.env["MAX_REDIRECTS"] = "5"; + process.env["IGNORE_SSL"] = "false"; + + try { + const result = resolveVariables({ + targets: [ + { + http: { + ignoreSSL: "${IGNORE_SSL}", + maxRedirects: "${MAX_REDIRECTS}", + url: "${HOST|localhost}", + }, + id: "env-http", + type: "http", + }, + ], + }); + + expect(result.issues).toHaveLength(0); + const http = (result.config as { targets: Array<{ http: Record }> }).targets[0]!.http; + expect(http["maxRedirects"]).toBe(5); + expect(http["ignoreSSL"]).toBe(false); + expect(http["url"]).toBe("localhost"); + } finally { + restoreEnv("MAX_REDIRECTS", originalMaxRedirects); + restoreEnv("IGNORE_SSL", originalIgnoreSsl); + } + }); + + test("解析优先级为 variables、环境变量、默认值", () => { + const originalPort = process.env["PORT"]; + const originalHost = process.env["HOST"]; + process.env["PORT"] = "3000"; + process.env["HOST"] = "env-host"; + + try { + const result = resolveVariables({ + targets: [ + { + http: { + body: "${MISSING|fallback}", + headers: { Host: "${HOST}" }, + maxRedirects: "${PORT}", + url: "${HOST_FROM_VARIABLES}", + }, + id: "priority-http", + type: "http", + }, + ], + variables: { HOST_FROM_VARIABLES: "config-host", PORT: 5432 }, + }); + + expect(result.issues).toHaveLength(0); + const http = (result.config as { targets: Array<{ http: Record }> }).targets[0]!.http; + expect(http["maxRedirects"]).toBe(5432); + expect((http["headers"] as Record)["Host"]).toBe("env-host"); + expect(http["body"]).toBe("fallback"); + expect(http["url"]).toBe("config-host"); + } finally { + restoreEnv("PORT", originalPort); + restoreEnv("HOST", originalHost); + } + }); + + test("替换范围仅 targets,且跳过 id 和 type 字段", () => { + const result = resolveVariables({ + defaults: { interval: "${interval}" }, + server: { host: "${host}" }, + targets: [ + { + cmd: { + args: ["--host", "${host}"], + env: { TOKEN: "${token}" }, + exec: "echo", + }, + id: "${id}", + type: "${type}", + }, + ], + variables: { host: "localhost", id: "resolved", interval: "30s", token: "abc", type: "cmd" }, + }); + + expect(result.issues).toHaveLength(0); + const config = result.config as { + defaults: { interval: string }; + server: { host: string }; + targets: Array<{ cmd: { args: string[]; env: Record }; id: string; type: string }>; + }; + expect(config.server.host).toBe("${host}"); + expect(config.defaults.interval).toBe("${interval}"); + expect(config.targets[0]!.id).toBe("${id}"); + expect(config.targets[0]!.type).toBe("${type}"); + expect(config.targets[0]!.cmd.args[1]).toBe("localhost"); + expect(config.targets[0]!.cmd.env["TOKEN"]).toBe("abc"); + }); + + test("默认值推断为 boolean(true/false)", () => { + const result = resolveVariables({ + targets: [ + { + http: { + ignoreSSL: "${DUMMY_SSL|false}", + url: "${HOST|localhost}", + }, + id: "default-bool", + type: "http", + }, + ], + }); + + expect(result.issues).toHaveLength(0); + const http = (result.config as { targets: Array<{ http: Record }> }).targets[0]!.http; + expect(http["ignoreSSL"]).toBe(false); + expect(typeof http["ignoreSSL"]).toBe("boolean"); + + const result2 = resolveVariables({ + targets: [ + { + http: { + ignoreSSL: "${DUMMY_SSL|true}", + url: "${HOST|localhost}", + }, + id: "default-bool-true", + type: "http", + }, + ], + }); + + expect(result2.issues).toHaveLength(0); + const http2 = (result2.config as { targets: Array<{ http: Record }> }).targets[0]!.http; + expect(http2["ignoreSSL"]).toBe(true); + expect(typeof http2["ignoreSSL"]).toBe("boolean"); + }); + + test("runtime 段不替换", () => { + const result = resolveVariables({ + runtime: { maxConcurrentChecks: 10, retention: "${retention}" }, + targets: [ + { + http: { url: "${host}" }, + id: "rt-no-replace", + type: "http", + }, + ], + variables: { host: "https://example.com", retention: "24h" }, + }); + + expect(result.issues).toHaveLength(0); + const config = result.config as { runtime: { retention: string } }; + expect(config.runtime.retention).toBe("${retention}"); + }); + + test("variables 段为非对象时报错", () => { + const strResult = extractVariables({ variables: "invalid" }); + expect(strResult.issues).toHaveLength(1); + expect(strResult.issues[0]!.code).toBe("invalid-type"); + + const numResult = extractVariables({ variables: 123 }); + expect(numResult.issues).toHaveLength(1); + expect(numResult.issues[0]!.code).toBe("invalid-type"); + + const nullResult = extractVariables({ variables: null }); + expect(nullResult.issues).toHaveLength(1); + expect(nullResult.issues[0]!.code).toBe("invalid-type"); + }); + + test("无 variables 段时环境变量仍可引用", () => { + const original = process.env["DIAL_TEST_ENV_ONLY"]; + process.env["DIAL_TEST_ENV_ONLY"] = "env-value"; + try { + const result = resolveVariables({ + targets: [ + { + http: { url: "${DIAL_TEST_ENV_ONLY}" }, + id: "env-only", + type: "http", + }, + ], + }); + + expect(result.issues).toHaveLength(0); + const http = (result.config as { targets: Array<{ http: { url: string } }> }).targets[0]!.http; + expect(http.url).toBe("env-value"); + } finally { + restoreEnv("DIAL_TEST_ENV_ONLY", original); + } + }); + + test("缺失变量收集所有 unresolved-variable issue", () => { + const result = resolveVariables({ + targets: [ + { + http: { + headers: { Authorization: "${missing_token}" }, + url: "${missing_base_url}/health/${missing_path}", + }, + id: "api-health", + type: "http", + }, + ], + }); + + expect(result.issues).toHaveLength(3); + expect(result.issues.map((item) => item.code)).toEqual([ + "unresolved-variable", + "unresolved-variable", + "unresolved-variable", + ]); + expect(result.issues.map((item) => item.path)).toEqual([ + "targets[0].http.headers.Authorization", + "targets[0].http.url", + "targets[0].http.url", + ]); + expect(result.issues.every((item) => item.targetId === "api-health")).toBe(true); + expect(result.issues.map((item) => item.message).join("\n")).toContain('变量 "missing_base_url"'); + }); +}); + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; +} diff --git a/tests/server/middleware.test.ts b/tests/server/middleware.test.ts index ba6a50e..f95a2b0 100644 --- a/tests/server/middleware.test.ts +++ b/tests/server/middleware.test.ts @@ -10,14 +10,14 @@ import { } from "../../src/server/middleware"; describe("validateTargetId", () => { - test("有效的 target ID 返回数字", () => { - const result = validateTargetId("123", "production"); + test("有效的 target ID 返回字符串", () => { + const result = validateTargetId("api-health_01", "production"); expect(result).not.toHaveProperty("status"); - expect((result as { id: number }).id).toBe(123); + expect((result as { id: string }).id).toBe("api-health_01"); }); test("无效的 target ID 返回 400", () => { - const invalid = ["0", "-1", "abc", "1.5", ""]; + const invalid = ["-1", "_abc", "has space", "1.5", ""]; for (const id of invalid) { const result = validateTargetId(id, "production"); diff --git a/tests/web/components/OverviewTab.test.tsx b/tests/web/components/OverviewTab.test.tsx index 54beb6c..a96c729 100644 --- a/tests/web/components/OverviewTab.test.tsx +++ b/tests/web/components/OverviewTab.test.tsx @@ -10,7 +10,7 @@ describe("OverviewTab", () => { const target: TargetStatus = { currentStreak: null, group: "default", - id: 1, + id: "1", interval: "30s", latestCheck: { durationMs: 100, @@ -40,7 +40,7 @@ describe("OverviewTab", () => { totalChecks: 20, upChecks: 19, }, - targetId: 1, + targetId: "1", trend: [], window: { bucket: "1h", from: "", to: "" }, }; diff --git a/tests/web/components/TargetBoard.test.tsx b/tests/web/components/TargetBoard.test.tsx index ebe4cdb..88264cb 100644 --- a/tests/web/components/TargetBoard.test.tsx +++ b/tests/web/components/TargetBoard.test.tsx @@ -20,7 +20,7 @@ describe("TargetBoard", () => { { currentStreak: null, group: "default", - id: 1, + id: "1", interval: "30s", latestCheck: null, name: "target-1", @@ -32,7 +32,7 @@ describe("TargetBoard", () => { { currentStreak: null, group: "production", - id: 2, + id: "2", interval: "30s", latestCheck: null, name: "target-2", diff --git a/tests/web/components/TargetDetailDrawer.test.tsx b/tests/web/components/TargetDetailDrawer.test.tsx index aedb278..b56f423 100644 --- a/tests/web/components/TargetDetailDrawer.test.tsx +++ b/tests/web/components/TargetDetailDrawer.test.tsx @@ -10,7 +10,7 @@ describe("TargetDetailDrawer", () => { const target: TargetStatus = { currentStreak: null, group: "default", - id: 1, + id: "1", interval: "30s", latestCheck: { durationMs: 100, @@ -40,7 +40,7 @@ describe("TargetDetailDrawer", () => { totalChecks: 20, upChecks: 19, }, - targetId: 1, + targetId: "1", trend: [], window: { bucket: "1h", from: "", to: "" }, }; diff --git a/tests/web/components/TargetGroup.test.tsx b/tests/web/components/TargetGroup.test.tsx index 6c521a9..7943e28 100644 --- a/tests/web/components/TargetGroup.test.tsx +++ b/tests/web/components/TargetGroup.test.tsx @@ -16,7 +16,7 @@ describe("TargetGroup", () => { { currentStreak: null, group: "default", - id: 1, + id: "1", interval: "30s", latestCheck: { durationMs: 100, @@ -34,7 +34,7 @@ describe("TargetGroup", () => { { currentStreak: null, group: "default", - id: 2, + id: "2", interval: "30s", latestCheck: { durationMs: 100, diff --git a/tests/web/constants/target-table-columns.test.ts b/tests/web/constants/target-table-columns.test.ts index d2a2244..5f2528c 100644 --- a/tests/web/constants/target-table-columns.test.ts +++ b/tests/web/constants/target-table-columns.test.ts @@ -21,7 +21,7 @@ function makeTarget(overrides: Partial = {}): TargetStatus { return { currentStreak: null, group: "default", - id: 1, + id: "1", interval: "5s", latestCheck: null, name: "test", diff --git a/tests/web/constants/target-table-sorters.test.ts b/tests/web/constants/target-table-sorters.test.ts index 02cdd04..3fe7d19 100644 --- a/tests/web/constants/target-table-sorters.test.ts +++ b/tests/web/constants/target-table-sorters.test.ts @@ -13,7 +13,7 @@ function makeTarget(overrides: Partial = {}): TargetStatus { return { currentStreak: null, group: "default", - id: 1, + id: "1", interval: "5s", latestCheck: null, name: "test",