1
0

feat: 配置变量系统与 target id/name 双字段标识

- 新增顶层 variables 段支持 string/number/boolean 字面量
- target 字符串字段支持 、、{...} 转义语法
- 变量解析优先级: variables -> process.env -> 默认值 -> 报错
- 完整引用保留原始类型,部分引用拼接为字符串
- 变量替换在 YAML 解析后、AJV 校验前执行
- 替换仅作用于 targets,跳过 id/type 字段
- target 新增必填 id 字段作为唯一标识,name 改为可选展示名称
- 数据库存储/API/前端全面迁移到 id 标识
- 统一 checker 运行时类型检查为 es-toolkit predicates
- 同步 delta specs 到主 specs,归档 config-variables 变更
This commit is contained in:
2026-05-17 00:37:54 +08:00
parent 366b3211c8
commit 7926514986
53 changed files with 1538 additions and 333 deletions

View File

@@ -36,6 +36,7 @@ src/
checker/ checker/
types.ts 基础类型定义ResolvedTargetBase、RawTargetConfig、DefaultsConfig、CheckResult 等基础 interface types.ts 基础类型定义ResolvedTargetBase、RawTargetConfig、DefaultsConfig、CheckResult 等基础 interface
config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析(输出 ResolvedConfig config-loader.ts YAML 配置解析、契约校验、语义校验与运行期解析(输出 ResolvedConfig
variables.ts 配置 variables 提取、target 字符串变量替换和 unresolved-variable issue 生成
schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口 schema/ TypeBox + Ajv 配置契约、schema fragments、issue 渲染和 schema 导出入口
builder.ts 全量 JSON Schema 组装(遍历 registry 生成) builder.ts 全量 JSON Schema 组装(遍历 registry 生成)
fragments.ts 共享 TypeBox schema 片段duration、size、operator 等) 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) dev.ts / main.ts → readRuntimeConfig(cli args, 仅提取 configPath)
→ bootstrap({ configPath, mode }) → bootstrap({ configPath, mode })
→ loadConfig(yaml) → ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets } → loadConfig(yamlYAML 解析 → 变量替换 → 契约校验 → 语义校验 → resolve)
→ ResolvedConfig{ host, port, dataDir, maxConcurrentChecks, retentionMs, targets }
→ ProbeStore(db) → store.syncTargets(targets) → ProbeStore(db) → store.syncTargets(targets)
→ ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start() → ProbeEngine(store, targets, maxConcurrentChecks, retentionMs) → engine.start()
→ startServer({ config, mode, store }) → startServer({ config, mode, store })
@@ -192,7 +194,7 @@ export function handleMetrics(idStr: string, url: URL, store: ProbeStore, mode:
- **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用 - **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用
- 前端不得 `import src/server/` 下的任何文件 - 前端不得 `import src/server/` 下的任何文件
- **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string` - **严格联合类型**优先于宽类型:如 `phase: "status" | "duration" | ...` 而非 `phase: string`
- **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetName` 等内部字段 - **后端内部扩展**`checker/types.ts``CheckResult` 通过 `extends` 共享版本的 `ApiCheckResult` 增加 `targetId` 等内部字段
- 存储层类型(`StoredTarget``StoredCheckResult`)独立定义,与 API 类型分离 - 存储层类型(`StoredTarget``StoredCheckResult`)独立定义,与 API 类型分离
- **Checker 类型分层** - **Checker 类型分层**
- `checker/types.ts` 定义 base interface`ResolvedTargetBase``RawTargetConfig``DefaultsConfig`),使用 index signature 支持扩展 - `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 配置契约与校验 ### 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` `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`、不启用类型强制转换、不注入默认值、不自动删除未知字段。 契约层使用 `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 路径。 契约校验和语义 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()` | 写入单条检查结果 | | `insertCheckResult()` | 写入单条检查结果 |
| `getTargets()` | 查询全部 targetsdefault 分组优先排序) | | `getTargets()` | 查询全部 targetsdefault 分组优先排序) |
| `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果(单次 SQL 聚合) | | `getLatestChecksMap()` | 批量获取每个 target 的最新检查结果(单次 SQL 聚合) |
@@ -459,8 +463,8 @@ TcpChecker implements Checker
**Schema** **Schema**
- `targets` 表:nameUNIQUE、type、target展示摘要、configJSON、interval_ms、timeout_ms、expectJSON、grp - `targets` 表:idTEXT PRIMARY KEY配置 target id、name展示名称、type、target展示摘要、configJSON、interval_ms、timeout_ms、expectJSON、grp
- `check_results`target_idFK CASCADE、timestamp、matched0/1、duration_ms、status_detail、failureJSON - `check_results`target_idTEXT FK CASCADE,引用配置 target id、timestamp、matched0/1、duration_ms、status_detail、failureJSON
- 复合索引:`(target_id, timestamp)` - 复合索引:`(target_id, timestamp)`
### 1.9 拨测引擎 ### 1.9 拨测引擎
@@ -469,7 +473,7 @@ TcpChecker implements Checker
- **并发控制**`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`,默认 20`acquire()` 阻塞等待 - **并发控制**`es-toolkit/Semaphore` 限制全局最大并发数(`maxConcurrentChecks`,默认 20`acquire()` 阻塞等待
- **Runner 选择**`engine.runCheck()` 通过 `checkerRegistry.get(target.type)` 获取 checker并调用 `checker.execute(target, { signal })` - **Runner 选择**`engine.runCheck()` 通过 `checkerRegistry.get(target.type)` 获取 checker并调用 `checker.execute(target, { signal })`
- **超时控制**`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Cmd 在 signal abort 时 `proc.kill()` - **超时控制**`ProbeEngine` 为每次检查创建 `AbortController` 并按 `target.timeoutMs` 触发 abortchecker 必须使用 `CheckerContext.signal` 感知超时HTTP 将 signal 传给 `fetch()`Cmd 在 signal abort 时 `proc.kill()`
- **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 通过 `targetNameToId` 缓存 name→id 映射 - **结果写入**:检查结果通过 `store.insertCheckResult()` 写入 SQLiteengine 基于配置 target id 确认目标仍存在
- **异常可观测**`probeGroup()``Promise.allSettled` 的 rejected 结果通过索引关联 target并写入 `phase:"internal"` 的失败记录 - **异常可观测**`probeGroup()``Promise.allSettled` 的 rejected 结果通过索引关联 target并写入 `phase:"internal"` 的失败记录
- **数据清理**:当 `retentionMs > 0`engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据 - **数据清理**:当 `retentionMs > 0`engine 启动时立即执行一次 `store.prune()`,之后每小时定时执行,按 `timestamp` 清理过期数据
- **生命周期**`start()`/`stop()` 管理定时器(含调度定时器和清理定时器),`stop()` 清理所有 `setInterval` - **生命周期**`start()`/`stop()` 管理定时器(含调度定时器和清理定时器),`stop()` 清理所有 `setInterval`

View File

@@ -69,6 +69,12 @@ runtime:
maxConcurrentChecks: 20 maxConcurrentChecks: 20
retention: "7d" retention: "7d"
variables:
env_name: "生产"
base_url: "https://api.example.com"
api_token: "Bearer demo-token"
sqlite_url: "sqlite:///path/to/db.sqlite"
defaults: defaults:
interval: "30s" interval: "30s"
timeout: "10s" timeout: "10s"
@@ -78,7 +84,8 @@ defaults:
maxOutputBytes: "1MB" maxOutputBytes: "1MB"
targets: targets:
- name: "Baidu" - id: "baidu-home"
name: "Baidu"
type: http type: http
http: http:
url: "https://www.baidu.com" url: "https://www.baidu.com"
@@ -86,10 +93,13 @@ targets:
status: [200] status: [200]
maxDurationMs: 10000 maxDurationMs: 10000
- name: "JSON API 示例" - id: "json-api"
name: "${env_name} JSON API 示例"
type: http type: http
http: http:
url: "https://httpbin.org/json" url: "${base_url}/json"
headers:
Authorization: "${api_token|Bearer fallback-token}"
expect: expect:
status: [200] status: [200]
headers: headers:
@@ -100,7 +110,8 @@ targets:
path: "$.slideshow.title" path: "$.slideshow.title"
equals: "Sample Slide Show" equals: "Sample Slide Show"
- name: "Bun 脚本检查" - id: "bun-script"
name: "Bun 脚本检查"
type: cmd type: cmd
cmd: cmd:
exec: "bun" exec: "bun"
@@ -110,10 +121,11 @@ targets:
stdout: stdout:
- contains: "ok" - contains: "ok"
- name: "SQLite 数据库检查" - id: "sqlite-active-users"
name: "SQLite 数据库检查"
type: db type: db
db: db:
url: "sqlite:///path/to/db.sqlite" url: "${sqlite_url}"
query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'" query: "SELECT COUNT(*) as cnt FROM users WHERE status = 'active'"
expect: expect:
maxDurationMs: 5000 maxDurationMs: 5000
@@ -150,17 +162,28 @@ targets:
| `cmd.maxOutputBytes` | 输出最大字节数 | `100MB` | | `cmd.maxOutputBytes` | 输出最大字节数 | `100MB` |
| `cmd.cwd` | 默认工作目录(相对于配置文件所在目录) | `.` | | `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 — 拨测目标列表(必填) #### targets — 拨测目标列表(必填)
每个 target 的通用字段: 每个 target 的通用字段:
| 字段 | 说明 | 必填 | | 字段 | 说明 | 必填 |
| ---------- | ----------------------------- | -------------------- | | ---------- | ---------------------------------------------------------- | -------------------- |
| `name` | 目标名称(全局唯一) | 是 | | `id` | 目标唯一标识,支持字母数字、下划线、连字符,不参与变量替换 | 是 |
| `type` | 目标类型:`http``cmd``db` | | | `name` | 展示名称,支持变量替换;省略时使用 `id` | |
| `group` | 分组名称 | 否,默认 `"default"` | | `type` | 目标类型:`http``cmd``db` | |
| `interval` | 覆盖全局拨测间隔 | 否 | | `group` | 分组名称 | 否,默认 `"default"` |
| `timeout` | 覆盖全局超时时间 | 否 | | `interval` | 覆盖全局拨测间隔 | 否 |
| `timeout` | 覆盖全局超时时间 | 否 |
**HTTP 类型** (`type: http`) **HTTP 类型** (`type: http`)
@@ -218,7 +241,7 @@ targets:
**JSON Schema**:仓库根目录导出 `probe-config.schema.json`,在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 即可在编辑器中获得提示和校验。 **JSON Schema**:仓库根目录导出 `probe-config.schema.json`,在 YAML 文件顶部添加 `# yaml-language-server: $schema=./probe-config.schema.json` 即可在编辑器中获得提示和校验。
> **注意:** 配置校验在启动时执行,非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`)外,未知字段会导致启动失败,请使用 YAML 注释。 > **注意:** 配置校验在启动时执行,非法配置会阻止启动并输出错误信息。除动态键值表(`headers`、`env`、`variables`)外,未知字段会导致启动失败,请使用 YAML 注释。
## 目标状态判定 ## 目标状态判定

View File

@@ -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校验失败并报错

View File

@@ -5,21 +5,21 @@
## Requirements ## Requirements
### Requirement: YAML 配置文件格式 ### 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 到 idHTTP 领域字段 MUST 放在 `http` 分组cmd 领域字段 MUST 放在 `cmd` 分组db 领域字段 MUST 放在 `db` 分组。HTTP target 的 `http` 分组 SHALL 支持可选的 `ignoreSSL`(布尔值)和 `maxRedirects`非负整数字段。Db target 的 `db` 分组 SHALL 支持 `url`(必填)和 `query`(可选)字段。
`defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。 `defaults.http` 分组 SHALL 仅支持 `headers`(可选)和 `maxBodyBytes`(可选)字段。`defaults.http` 分组 MUST NOT 支持 `method` 字段。
#### Scenario: 完整配置文件解析 #### Scenario: 完整配置文件解析
- **WHEN** 系统启动并读取包含 server、runtime、defaults、targets含 group 字段)的 YAML 配置文件 - **WHEN** 系统启动并读取包含 server、runtime、variables、defaults、targetsid、group 字段)的 YAML 配置文件
- **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner - **THEN** 系统 SHALL 正确解析所有字段并用于初始化服务、调度引擎和对应 checker runner
#### Scenario: 最简 HTTP 配置文件解析 #### Scenario: 最简 HTTP 配置文件解析
- **WHEN** 系统读取只包含一个 `type: http` target 和 `http.url` 的 YAML 配置文件(省略 server、runtime、defaults 和 expect - **WHEN** 系统读取只包含一个 `type: http` target(含 `id``http.url`的 YAML 配置文件(省略 server、runtime、variables、defaults 和 expect
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段host=127.0.0.1, port=3000, dir=./data, interval=30s, timeout=10s, runtime.maxConcurrentChecks=20, http.method=GET, http.maxBodyBytes=100MB, http.ignoreSSL=false, http.maxRedirects=0, group="default" - **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 配置文件解析 #### Scenario: 最简 cmd 配置文件解析
- **WHEN** 系统读取只包含一个 `type: cmd` target 和 `cmd.exec` 的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: cmd` target(含 `id``cmd.exec`的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, cmd.cwd 为配置文件所在目录, cmd.maxOutputBytes=100MB, name fallback 到 id
#### Scenario: per-target 配置覆盖全局默认值 #### Scenario: per-target 配置覆盖全局默认值
- **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段 - **WHEN** 某个 target 指定 interval、timeout 或对应领域分组中的默认字段
@@ -34,8 +34,8 @@
- **THEN** 系统 SHALL 解析该字段并在执行时允许最多跟随 5 次重定向 - **THEN** 系统 SHALL 解析该字段并在执行时允许最多跟随 5 次重定向
#### Scenario: 最简 db 配置文件解析 #### Scenario: 最简 db 配置文件解析
- **WHEN** 系统读取只包含一个 `type: db` target 和 `db.url` 的 YAML 配置文件 - **WHEN** 系统读取只包含一个 `type: db` target(含 `id``db.url`的 YAML 配置文件
- **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default" - **THEN** 系统 SHALL 使用内置默认值填充未指定的字段interval=30s, timeout=10s, group="default", name fallback 到 id
#### Scenario: defaults.http.method 触发校验错误 #### Scenario: defaults.http.method 触发校验错误
- **WHEN** 配置文件中出现 `defaults.http.method` 字段 - **WHEN** 配置文件中出现 `defaults.http.method` 字段
@@ -65,16 +65,16 @@
- **THEN** 系统 SHALL 以错误退出并提示文件不存在 - **THEN** 系统 SHALL 以错误退出并提示文件不存在
### Requirement: 配置校验 ### 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`,最终由配置加载流程统一渲染为中文错误信息。 契约校验和语义 validator SHALL 统一产出 `ConfigValidationIssue`,最终由配置加载流程统一渲染为中文错误信息。
系统 SHALL 导出完整 `probe-config.schema.json`,该文件 SHALL 与运行期 TypeBox fragments 生成的 JSON Schema 保持一致,用于用户配置引用和编辑器提示。 系统 SHALL 导出完整 `probe-config.schema.json`,该文件 SHALL 与运行期 TypeBox fragments 生成的 JSON Schema 保持一致,用于用户配置引用和编辑器提示。
`headers``env` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。 `headers``env``variables` 等明确声明为动态键值表的对象外,配置中的未知字段 SHALL 导致启动期配置错误。系统 MUST NOT 静默忽略未知字段。
#### Scenario: target 缺少必填字段 #### Scenario: target 缺少必填字段
- **WHEN** YAML 中某个 target 缺少 name 或 type 字段 - **WHEN** YAML 中某个 target 缺少 id 或 type 字段
- **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段 - **THEN** 系统 SHALL 以错误退出,提示哪个 target 缺少哪个字段
#### Scenario: HTTP target 缺少 url #### Scenario: HTTP target 缺少 url
@@ -89,9 +89,13 @@
- **WHEN** YAML 中某个 target 的 type 不是已注册 checker 类型 - **WHEN** YAML 中某个 target 的 type 不是已注册 checker 类型
- **THEN** 系统 SHALL 以错误退出,提示不支持的 target type 和当前支持的 type 列表 - **THEN** 系统 SHALL 以错误退出,提示不支持的 target type 和当前支持的 type 列表
#### Scenario: target name 重复 #### Scenario: target id 重复
- **WHEN** YAML 中存在两个 name 相同的 target - **WHEN** YAML 中存在两个 id 相同的 target
- **THEN** 系统 SHALL 以错误退出,提示重复的 name - **THEN** 系统 SHALL 以错误退出,提示重复的 id
#### Scenario: target id 不合法
- **WHEN** YAML 中某个 target 的 id 不符合 `[a-zA-Z0-9][a-zA-Z0-9_-]*` 规则
- **THEN** 系统 SHALL 以错误退出,提示 id 命名不合法
#### Scenario: group 字段类型校验 #### Scenario: group 字段类型校验
- **WHEN** YAML 中某个 target 的 `group` 字段不是字符串 - **WHEN** YAML 中某个 target 的 `group` 字段不是字符串
@@ -223,15 +227,15 @@
#### Scenario: 配置生命周期分离 #### Scenario: 配置生命周期分离
- **WHEN** 系统加载配置文件 - **WHEN** 系统加载配置文件
- **THEN** 系统 SHALL 按 `unknown -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行契约校验、语义校验和运行期配置解析 - **THEN** 系统 SHALL 按 `unknown -> 变量替换 -> RawProbeConfig -> ValidatedProbeConfig -> ResolvedConfig` 的顺序执行变量替换、契约校验、语义校验和运行期配置解析
#### Scenario: 结构化校验 issue #### Scenario: 结构化校验 issue
- **WHEN** 契约校验语义 validator 发现非法配置 - **WHEN** 契约校验语义 validator 或变量替换阶段发现非法配置
- **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误 - **THEN** 系统 SHALL 先生成包含 code、path、message 和可选 targetName 的结构化 `ConfigValidationIssue`,再统一渲染为中文错误信息
#### Scenario: 导出配置 JSON Schema #### Scenario: 导出配置 JSON Schema
- **WHEN** 仓库生成或检查配置契约 - **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` 系统 SHALL 支持使用单位字符串配置读取上限,单位包括 `B``KB``MB``GB`
#### Scenario: 解析 MB #### Scenario: 解析 MB

View File

@@ -34,7 +34,7 @@
#### Scenario: 配置变更后重新同步 #### Scenario: 配置变更后重新同步
- **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启 - **WHEN** YAML 配置发生变更(新增、删除或修改目标)后重启
- **THEN** 系统 SHALL 根据 name 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段) - **THEN** 系统 SHALL 根据 id 字段匹配:新增的插入、删除的移除、修改的更新(含 grp 字段)
### Requirement: check_results 表追加写入 ### Requirement: check_results 表追加写入
系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。 系统 SHALL 将每次检查结果追加写入 check_results 表,不更新或删除已有记录。

View File

@@ -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 不要求全局唯一)

View File

@@ -104,7 +104,7 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
"required": [ "required": [
"name", "id",
"type", "type",
"http" "http"
], ],
@@ -403,6 +403,10 @@
"group": { "group": {
"type": "string" "type": "string"
}, },
"id": {
"minLength": 1,
"type": "string"
},
"interval": { "interval": {
"type": "string" "type": "string"
}, },
@@ -495,7 +499,7 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
"required": [ "required": [
"name", "id",
"type", "type",
"cmd" "cmd"
], ],
@@ -635,6 +639,10 @@
"group": { "group": {
"type": "string" "type": "string"
}, },
"id": {
"minLength": 1,
"type": "string"
},
"interval": { "interval": {
"type": "string" "type": "string"
}, },
@@ -694,7 +702,7 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
"required": [ "required": [
"name", "id",
"type", "type",
"db" "db"
], ],
@@ -860,6 +868,10 @@
"group": { "group": {
"type": "string" "type": "string"
}, },
"id": {
"minLength": 1,
"type": "string"
},
"interval": { "interval": {
"type": "string" "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", "$id": "https://dial.local/probe-config.schema.json",

View File

@@ -1,3 +1,5 @@
# yaml-language-server: $schema=./probe-config.schema.json
server: server:
host: "127.0.0.1" host: "127.0.0.1"
port: 3000 port: 3000
@@ -14,10 +16,17 @@ defaults:
cmd: cmd:
maxOutputBytes: "1MB" maxOutputBytes: "1MB"
variables:
env_name: "演示"
httpbin_base: "https://httpbin.org"
api_token: "Bearer demo-token"
sqlite_url: "sqlite://:memory:"
targets: targets:
# ========== HTTP targets ========== # ========== HTTP targets ==========
- name: "Baidu 首页可用" - id: "baidu-home"
name: "Baidu 首页可用"
type: http type: http
group: "搜索引擎" group: "搜索引擎"
http: http:
@@ -26,15 +35,17 @@ targets:
status: [200] status: [200]
maxDurationMs: 5000 maxDurationMs: 5000
- name: "JSON API — 完整流水线" - id: "httpbin-json"
name: "${env_name} JSON API — 完整流水线"
type: http type: http
group: "后端服务" group: "后端服务"
interval: "1m" interval: "1m"
timeout: "15s" timeout: "15s"
http: http:
url: "https://httpbin.org/json" url: "${httpbin_base}/json"
headers: headers:
Accept: "application/json" Accept: "application/json"
Authorization: "${api_token|Bearer fallback-token}"
expect: expect:
headers: headers:
Content-Type: Content-Type:
@@ -49,10 +60,11 @@ targets:
contains: "Wake" contains: "Wake"
- regex: '"title"' - regex: '"title"'
- name: "POST 接口测试" - id: "httpbin-post"
name: "POST 接口测试"
type: http type: http
http: http:
url: "https://httpbin.org/post" url: "${httpbin_base}/post"
method: POST method: POST
headers: headers:
Content-Type: "application/json" Content-Type: "application/json"
@@ -69,7 +81,8 @@ targets:
# ========== Cmd targets ========== # ========== Cmd targets ==========
- name: "Bun 版本输出匹配" - id: "bun-version"
name: "Bun 版本输出匹配"
type: cmd type: cmd
group: "系统检查" group: "系统检查"
cmd: cmd:
@@ -80,7 +93,8 @@ targets:
stdout: stdout:
- match: "^\\d+\\.\\d+\\.\\d+" - match: "^\\d+\\.\\d+\\.\\d+"
- name: "多规则 stdout 顺序校验" - id: "bun-stdout-rules"
name: "多规则 stdout 顺序校验"
type: cmd type: cmd
interval: "5m" interval: "5m"
cmd: cmd:
@@ -92,7 +106,8 @@ targets:
- match: "\\d+\\.\\d+\\.\\d+" - match: "\\d+\\.\\d+\\.\\d+"
- contains: "healthy" - contains: "healthy"
- name: "stderr 内容检查" - id: "bun-stderr"
name: "stderr 内容检查"
type: cmd type: cmd
cmd: cmd:
exec: "bun" exec: "bun"
@@ -104,18 +119,20 @@ targets:
# ========== DB targets ========== # ========== DB targets ==========
- name: "SQLite 内存数据库连接测试" - id: "sqlite-connect"
name: "SQLite 内存数据库连接测试"
type: db type: db
group: "数据库" group: "数据库"
db: db:
url: "sqlite://:memory:" url: "${sqlite_url}"
expect: expect:
maxDurationMs: 1000 maxDurationMs: 1000
- name: "SQLite 内存数据库多列结果校验" - id: "sqlite-query"
name: "SQLite 内存数据库多列结果校验"
type: db type: db
db: db:
url: "sqlite://:memory:" url: "${sqlite_url}"
query: "SELECT 1 as id, 'Alice' as name, 'engineer' as role" query: "SELECT 1 as id, 'Alice' as name, 'engineer' as role"
expect: expect:
rowCount: rowCount:

View File

@@ -1,3 +1,5 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import type { ConfigValidationIssue } from "./schema/issues"; import type { ConfigValidationIssue } from "./schema/issues";
@@ -8,6 +10,7 @@ import { issue, throwConfigIssues } from "./schema/issues";
import { asValidatedConfig, type RawProbeConfig } from "./schema/types"; import { asValidatedConfig, type RawProbeConfig } from "./schema/types";
import { validateProbeConfigContract } from "./schema/validate"; import { validateProbeConfigContract } from "./schema/validate";
import { parseDuration } from "./utils"; import { parseDuration } from "./utils";
import { resolveVariables } from "./variables";
const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3000; const DEFAULT_PORT = 3000;
@@ -41,11 +44,17 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
throw new Error("配置文件内容为空或格式无效"); throw new Error("配置文件内容为空或格式无效");
} }
const contractResult = validateProbeConfigContract(parsed, checkerRegistry); const variableResult = resolveVariables(parsed);
if (contractResult.config === null && !canRunSemanticValidation(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); throwConfigIssues(contractResult.issues);
} }
const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig; const semanticInput = (contractResult.config ?? resolvedVariablesConfig) as RawProbeConfig;
const validationIssues = validateConfig(semanticInput); const validationIssues = validateConfig(semanticInput);
const allIssues = [...contractResult.issues, ...validationIssues]; const allIssues = [...contractResult.issues, ...validationIssues];
@@ -88,14 +97,14 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
} }
function canRunSemanticValidation(value: unknown): boolean { function canRunSemanticValidation(value: unknown): boolean {
return typeof value === "object" && value !== null; return isPlainObject(value);
} }
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] { function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: ConfigValidationIssue[] = []; const result: ConfigValidationIssue[] = [];
for (const item of issues) { 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; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
result.push(item); result.push(item);
@@ -103,16 +112,12 @@ function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[]
return result; return result;
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export { parseDuration } from "./utils"; export { parseDuration } from "./utils";
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number { function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS; if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
if ( if (
typeof runtime.maxConcurrentChecks !== "number" || !isNumber(runtime.maxConcurrentChecks) ||
!Number.isInteger(runtime.maxConcurrentChecks) || !Number.isInteger(runtime.maxConcurrentChecks) ||
runtime.maxConcurrentChecks <= 0 runtime.maxConcurrentChecks <= 0
) )
@@ -145,29 +150,36 @@ function resolveTarget(
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] { function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
const issues: 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")); issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
return issues; return issues;
} }
const names = new Set<string>(); const ids = new Set<string>();
const supportedTypes = checkerRegistry.supportedTypes; const supportedTypes = checkerRegistry.supportedTypes;
for (let i = 0; i < config.targets.length; i++) { for (let i = 0; i < config.targets.length; i++) {
const rawTarget = config.targets[i] as unknown; const rawTarget = config.targets[i] as unknown;
if (!isRecord(rawTarget)) { if (!isPlainObject(rawTarget)) {
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象")); issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
continue; continue;
} }
const raw = rawTarget; const raw = rawTarget as Record<string, unknown>;
const name = raw["name"]; const id: unknown = raw["id"];
if (!name || typeof name !== "string" || name.trim() === "") { if (!isString(id) || id.trim() === "") {
issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段")); issues.push(issue("required", `targets[${i}].id`, "缺少 id 字段"));
continue; continue;
} }
const type = raw["type"]; if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id)) {
if (!type || typeof type !== "string") { 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)); issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
continue; continue;
} }
@@ -183,16 +195,16 @@ function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
); );
} }
const group = raw["group"]; const group: unknown = raw["group"];
if (group !== undefined && typeof group !== "string") { if (group !== undefined && !isString(group)) {
issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name)); issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name));
} }
if (names.has(name)) { if (ids.has(id)) {
issues.push(issue("duplicate-name", `targets[${i}].name`, `target name 重复: "${name}"`, name)); issues.push(issue("duplicate-id", `targets[${i}].id`, `target id 重复: "${id}"`, name));
} }
names.add(name); ids.add(id);
} }
for (const checker of checkerRegistry.definitions) { 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?.interval, "defaults.interval", issues);
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues); validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
validateDurationValue( validateDurationValue(
typeof config.runtime?.retention === "string" ? config.runtime.retention : undefined, isString(config.runtime?.retention) ? config.runtime.retention : undefined,
"runtime.retention", "runtime.retention",
issues, issues,
); );
for (let i = 0; i < config.targets.length; i++) { for (let i = 0; i < config.targets.length; i++) {
const target = config.targets[i] as unknown; const target = config.targets[i] as unknown;
if (!isRecord(target)) continue; if (!isPlainObject(target)) continue;
const targetName = typeof target["name"] === "string" ? target["name"] : undefined; const targetRecord = target as Record<string, unknown>;
const targetNameValue: unknown = targetRecord["name"];
const targetIdValue: unknown = targetRecord["id"];
const targetName = isString(targetNameValue)
? targetNameValue
: isString(targetIdValue)
? targetIdValue
: undefined;
validateDurationValue( validateDurationValue(
typeof target["interval"] === "string" ? target["interval"] : undefined, isString(targetRecord["interval"]) ? targetRecord["interval"] : undefined,
`targets[${i}].interval`, `targets[${i}].interval`,
issues, issues,
targetName, targetName,
); );
validateDurationValue( validateDurationValue(
typeof target["timeout"] === "string" ? target["timeout"] : undefined, isString(targetRecord["timeout"]) ? targetRecord["timeout"] : undefined,
`targets[${i}].timeout`, `targets[${i}].timeout`,
issues, issues,
targetName, targetName,

View File

@@ -12,7 +12,7 @@ export class ProbeEngine {
private retentionMs: number; private retentionMs: number;
private semaphore: Semaphore; private semaphore: Semaphore;
private store: ProbeStore; private store: ProbeStore;
private targetNameToId = new Map<string, number>(); private targetIds = new Set<string>();
private targets: ResolvedTargetBase[]; private targets: ResolvedTargetBase[];
private timers: Array<ReturnType<typeof setInterval>> = []; private timers: Array<ReturnType<typeof setInterval>> = [];
@@ -77,7 +77,7 @@ export class ProbeEngine {
failure: errorFailure("internal", "engine", formatReason(result.reason)), failure: errorFailure("internal", "engine", formatReason(result.reason)),
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetName: target.name, targetId: target.id,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
@@ -85,9 +85,9 @@ export class ProbeEngine {
} }
private refreshCache(): void { private refreshCache(): void {
this.targetNameToId.clear(); this.targetIds.clear();
for (const target of this.store.getTargets()) { 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 { private writeResult(result: CheckResult): void {
const targetId = this.targetNameToId.get(result.targetName); if (!this.targetIds.has(result.targetId)) return;
if (!targetId) return;
this.store.insertCheckResult({ this.store.insertCheckResult({
durationMs: result.durationMs, durationMs: result.durationMs,
failure: result.failure, failure: result.failure,
matched: result.matched, matched: result.matched,
statusDetail: result.statusDetail, statusDetail: result.statusDetail,
targetId, targetId: result.targetId,
timestamp: result.timestamp, timestamp: result.timestamp,
}); });
} }

View File

@@ -1,3 +1,6 @@
import { isString } from "es-toolkit";
import { isObject } from "es-toolkit/compat";
import type { CheckFailure } from "../types"; import type { CheckFailure } from "../types";
export function errorFailure(phase: CheckFailure["phase"], path: string, message: string): CheckFailure { 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 { export function truncateActual(value: unknown, maxLen = 200): unknown {
if (value === undefined || value === null) return value; 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 === undefined) return value;
if (str.length <= maxLen) return value; if (str.length <= maxLen) return value;
return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`; return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;

View File

@@ -1,4 +1,5 @@
import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit"; import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ExpectOperator, ExpectValue } from "../types"; import type { ExpectOperator, ExpectValue } from "../types";
@@ -14,7 +15,7 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
break; break;
case "empty": { case "empty": {
const isEmpty = 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; if (expected !== isEmpty) return false;
break; break;
} }
@@ -67,7 +68,7 @@ export function evaluateJsonPath(json: unknown, path: string): unknown {
if (bracketMatch) { if (bracketMatch) {
current = (current as Record<string, unknown>)?.[bracketMatch[1]!]; current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
const idx = parseInt(bracketMatch[2]!, 10); 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]; current = current[idx];
} else { } else {
if (current === null || current === undefined) return undefined; if (current === null || current === undefined) return undefined;

View File

@@ -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 { ConfigValidationIssue } from "../schema/issues";
import type { JsonValue } from "../types"; import type { JsonValue } from "../types";
@@ -9,17 +12,17 @@ const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function isJsonValue(value: unknown): value is JsonValue { export function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true; if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true; if (isString(value) || isBoolean(value)) return true;
if (typeof value === "number") return Number.isFinite(value); if (isNumber(value)) return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue); if (isArray(value)) return value.every(isJsonValue);
if (typeof value === "object") { if (isPlainObject(value)) {
return Object.values(value as Record<string, unknown>).every(isJsonValue); return Object.values(value).every(isJsonValue);
} }
return false; return false;
} }
export function isPlainRecord(value: unknown): value is Record<string, unknown> { export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return isPlainObject(value);
} }
export function validateOperatorObject( export function validateOperatorObject(
@@ -54,21 +57,21 @@ export function validateOperatorValue(
): ConfigValidationIssue[] { ): ConfigValidationIssue[] {
switch (key) { switch (key) {
case "contains": case "contains":
return typeof value === "string" ? [] : [issue("invalid-type", path, "必须为字符串", targetName)]; return isString(value) ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
case "empty": case "empty":
case "exists": case "exists":
return typeof value === "boolean" ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)]; return isBoolean(value) ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
case "equals": case "equals":
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)]; return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
case "gt": case "gt":
case "gte": case "gte":
case "lt": case "lt":
case "lte": case "lte":
return typeof value === "number" && Number.isFinite(value) return isNumber(value) && Number.isFinite(value)
? [] ? []
: [issue("invalid-type", path, "必须为有限数字", targetName)]; : [issue("invalid-type", path, "必须为有限数字", targetName)];
case "match": case "match":
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)]; if (!isString(value)) return [issue("invalid-type", path, "必须为字符串", targetName)];
try { try {
new RegExp(value); new RegExp(value);
} catch { } catch {

View File

@@ -41,7 +41,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)), failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -74,7 +74,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "execution", "输出读取失败"), failure: errorFailure("exitCode", "execution", "输出读取失败"),
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -90,7 +90,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`), failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`),
matched: false, matched: false,
statusDetail: `exitCode=${exitCode}`, statusDetail: `exitCode=${exitCode}`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -101,7 +101,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`), failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -113,7 +113,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: exitCodeResult.failure, failure: exitCodeResult.failure,
matched: false, matched: false,
statusDetail: `exitCode=${exitCode}`, statusDetail: `exitCode=${exitCode}`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -125,7 +125,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: durationResult.failure, failure: durationResult.failure,
matched: false, matched: false,
statusDetail: `exitCode=${exitCode}`, statusDetail: `exitCode=${exitCode}`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -138,7 +138,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: stdoutResult.failure, failure: stdoutResult.failure,
matched: false, matched: false,
statusDetail: `exitCode=${exitCode}`, statusDetail: `exitCode=${exitCode}`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -152,7 +152,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: stderrResult.failure, failure: stderrResult.failure,
matched: false, matched: false,
statusDetail: `exitCode=${exitCode}`, statusDetail: `exitCode=${exitCode}`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -163,7 +163,7 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
failure: null, failure: null,
matched: true, matched: true,
statusDetail: `exitCode=${exitCode}`, statusDetail: `exitCode=${exitCode}`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -189,8 +189,9 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
}, },
expect: target.expect as CommandExpectConfig | undefined, expect: target.expect as CommandExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name, name: t.name ?? t.id,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "cmd", type: "cmd",
} satisfies ResolvedCommandTarget; } satisfies ResolvedCommandTarget;

View File

@@ -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 { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
@@ -7,7 +10,8 @@ import { parseSize } from "../../utils";
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] { export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: 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"])) { if (isSizeInput(defaults?.["maxOutputBytes"])) {
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.cmd.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++) { for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown; const target = input.targets[i] as unknown;
if (!isRecord(target)) continue; if (!isPlainObject(target)) continue;
if (target["type"] !== "cmd") continue; if (target["type"] !== "cmd") continue;
issues.push(...validateCommandTarget(target, `targets[${i}]`)); issues.push(...validateCommandTarget(target, `targets[${i}]`));
} }
@@ -24,25 +28,22 @@ export function validateCommandConfig(input: CheckerValidationInput): ConfigVali
} }
function getTargetName(target: Record<string, unknown>): string | undefined { function getTargetName(target: Record<string, unknown>): 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 { function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0; return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
} }
function isSizeInput(value: unknown): value is number | string { function isSizeInput(value: unknown): value is number | string {
return typeof value === "number" || typeof value === "string"; return isNumber(value) || isString(value);
} }
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] { function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target); const targetName = getTargetName(target);
const expect = target["expect"]; const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return []; if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
if (expect["stdout"] !== undefined) { if (expect["stdout"] !== undefined) {
@@ -61,12 +62,12 @@ function validateCommandTarget(target: Record<string, unknown>, path: string): C
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target); const targetName = getTargetName(target);
const cmd = target["cmd"]; const cmd = target["cmd"];
if (!isRecord(cmd)) { if (!isPlainObject(cmd)) {
issues.push(issue("required", joinPath(path, "cmd"), "缺少 cmd.exec 字段", targetName)); issues.push(issue("required", joinPath(path, "cmd"), "缺少 cmd.exec 字段", targetName));
issues.push(...validateCommandExpect(target, path)); issues.push(...validateCommandExpect(target, path));
return issues; 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)); issues.push(issue("required", joinPath(joinPath(path, "cmd"), "exec"), "缺少 cmd.exec 字段", targetName));
} }
if (isSizeInput(cmd["maxOutputBytes"])) { 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[] { 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)); return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
} }

View File

@@ -1,5 +1,6 @@
import { SQL } from "bun"; import { SQL } from "bun";
import { isError } from "es-toolkit"; import { isError } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
@@ -50,7 +51,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)), failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -65,7 +66,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: durationResult.failure, failure: durationResult.failure,
matched: false, matched: false,
statusDetail: "connected", statusDetail: "connected",
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -74,7 +75,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: null, failure: null,
matched: true, matched: true,
statusDetail: "connected", statusDetail: "connected",
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -90,7 +91,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)), failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -104,7 +105,7 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`), failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -116,8 +117,8 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
durationMs, durationMs,
failure: durationResult.failure, failure: durationResult.failure,
matched: false, matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -130,8 +131,8 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
durationMs, durationMs,
failure: rowCountResult.failure, failure: rowCountResult.failure,
matched: false, matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -145,8 +146,8 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
durationMs, durationMs,
failure: rowsResult.failure, failure: rowsResult.failure,
matched: false, matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -156,8 +157,8 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
durationMs, durationMs,
failure: null, failure: null,
matched: true, matched: true,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`, statusDetail: `${isArray(rows) ? rows.length : 0} rows`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} finally { } finally {
@@ -181,8 +182,9 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
}, },
expect: target.expect as DbExpectConfig | undefined, expect: target.expect as DbExpectConfig | undefined,
group: target.group ?? "default", group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name, name: t.name ?? t.id,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "db", type: "db",
} satisfies ResolvedDbTarget; } satisfies ResolvedDbTarget;

View File

@@ -1,3 +1,6 @@
import { isPlainObject } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type { ExpectResult } from "../../expect/types"; import type { ExpectResult } from "../../expect/types";
import type { ExpectOperator, ExpectValue } from "../../types"; import type { ExpectOperator, ExpectValue } from "../../types";
@@ -5,7 +8,7 @@ import { mismatchFailure } from "../../expect/failure";
import { checkExpectValue } from "../../expect/operator"; import { checkExpectValue } from "../../expect/operator";
export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult { 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); const matched = checkExpectValue(actual, op);
if (!matched) { if (!matched) {
return { return {
@@ -17,7 +20,7 @@ export function checkRowCount(rows: unknown, op: ExpectOperator): ExpectResult {
} }
export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue>>): ExpectResult { export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue>>): ExpectResult {
if (!Array.isArray(rows)) { if (!isArray(rows)) {
return { return {
failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"), failure: mismatchFailure("row", "rows", rules, rows, "查询结果不是数组"),
matched: false, matched: false,
@@ -34,7 +37,7 @@ export function checkRows(rows: unknown, rules: Array<Record<string, ExpectValue
} }
const row = rows[i]! as null | Record<string, unknown> | undefined; const row = rows[i]! as null | Record<string, unknown> | undefined;
if (!row || typeof row !== "object" || Array.isArray(row)) { if (!isPlainObject(row)) {
return { return {
failure: mismatchFailure("row", `rows[${i}]`, "object", row, `${i + 1} 行不是对象`), failure: mismatchFailure("row", `rows[${i}]`, "object", row, `${i + 1} 行不是对象`),
matched: false, matched: false,

View File

@@ -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 { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
@@ -10,7 +13,7 @@ export function validateDbConfig(input: CheckerValidationInput): ConfigValidatio
for (let i = 0; i < input.targets.length; i++) { for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown; const target = input.targets[i] as unknown;
if (!isRecord(target)) continue; if (!isPlainObject(target)) continue;
if (target["type"] !== "db") continue; if (target["type"] !== "db") continue;
issues.push(...validateDbTarget(target, `targets[${i}]`)); issues.push(...validateDbTarget(target, `targets[${i}]`));
} }
@@ -22,28 +25,29 @@ function collectRowOperators(rows: unknown[], path: string, targetName?: string)
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const row = rows[i]!; const row = rows[i]!;
if (!isRecord(row)) { if (!isPlainObject(row)) {
issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName)); issues.push(issue("invalid-type", `${path}[${i}]`, "必须为对象", targetName));
continue; continue;
} }
for (const [col, value] of Object.entries(row)) { for (const [col, value] of Object.entries(row)) {
const colPath = `${path}[${i}].${col}`; 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 正则 // 检查 match 正则
const match = value["match"]; const valueRecord = value as Record<string, unknown>;
if (typeof match === "string") { const match: unknown = valueRecord["match"];
if (isString(match)) {
try { try {
new RegExp(match); new RegExp(match);
} catch { } catch {
issues.push(issue("invalid-regex", colPath, "正则不合法", targetName)); issues.push(issue("invalid-regex", colPath, "正则不合法", targetName));
} }
if (typeof match === "string" && isUnsafeRegex(match)) { if (isUnsafeRegex(match)) {
issues.push(issue("unsafe-regex", colPath, "正则存在 ReDoS 风险", targetName)); issues.push(issue("unsafe-regex", colPath, "正则存在 ReDoS 风险", targetName));
} }
} }
} }
// 校验 operator 对象 // 校验 operator 对象
if (isRecord(value)) { if (isPlainObject(value)) {
issues.push(...validateOperatorObject(value, colPath, targetName, { requireAtLeastOne: false })); 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, unknown>): string | undefined { function getTargetName(target: Record<string, unknown>): 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 { function isNonNegativeFiniteNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value) && value >= 0; return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
} }
function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] { function validateDbExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const targetName = getTargetName(target); const targetName = getTargetName(target);
const expect = target["expect"]; const expect = target["expect"];
if (expect === undefined || expect === null || !isRecord(expect)) return []; if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect"); const expectPath = joinPath(path, "expect");
@@ -79,7 +80,7 @@ function validateDbExpect(target: Record<string, unknown>, path: string): Config
} }
if (expect["rows"] !== undefined) { if (expect["rows"] !== undefined) {
if (!Array.isArray(expect["rows"])) { if (!isArray(expect["rows"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName)); issues.push(issue("invalid-type", joinPath(expectPath, "rows"), "必须为数组", targetName));
} else { } else {
issues.push(...collectRowOperators(expect["rows"], joinPath(expectPath, "rows"), targetName)); issues.push(...collectRowOperators(expect["rows"], joinPath(expectPath, "rows"), targetName));
@@ -102,20 +103,20 @@ function validateDbTarget(target: Record<string, unknown>, path: string): Config
const targetName = getTargetName(target); const targetName = getTargetName(target);
const db = target["db"]; const db = target["db"];
if (!isRecord(db)) { if (!isPlainObject(db)) {
issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName)); issues.push(issue("required", joinPath(path, "db"), "缺少 db.url 字段", targetName));
issues.push(...validateDbExpect(target, path)); issues.push(...validateDbExpect(target, path));
return issues; return issues;
} }
// url 必填 // 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)); issues.push(issue("required", joinPath(joinPath(path, "db"), "url"), "缺少 db.url 字段", targetName));
} }
// query 可选但不能为空字符串 // query 可选但不能为空字符串
if (db["query"] !== undefined) { if (db["query"] !== undefined) {
if (typeof db["query"] !== "string") { if (!isString(db["query"])) {
issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName)); issues.push(issue("invalid-type", joinPath(joinPath(path, "db"), "query"), "必须为字符串", targetName));
} else if (db["query"].trim() === "") { } else if (db["query"].trim() === "") {
issues.push( issues.push(

View File

@@ -1,5 +1,6 @@
import { DOMParser } from "@xmldom/xmldom"; import { DOMParser } from "@xmldom/xmldom";
import * as cheerio from "cheerio"; import * as cheerio from "cheerio";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath"; import * as xpath from "xpath";
import type { ExpectResult } from "../../expect/types"; 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); 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 { return {
failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`), failure: mismatchFailure("body", fullPath, "node found", "no match", `xpath ${path} not found`),
matched: false, matched: false,

View File

@@ -1,4 +1,5 @@
import { isError } from "es-toolkit"; import { isError } from "es-toolkit";
import { isObject } from "es-toolkit/compat";
import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
@@ -95,7 +96,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
), ),
matched: false, matched: false,
statusDetail: null, statusDetail: null,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }
@@ -122,8 +123,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
method, method,
url: t.http.url, url: t.http.url,
}, },
id: t.id,
intervalMs: context.defaultIntervalMs, intervalMs: context.defaultIntervalMs,
name: t.name, name: t.name ?? t.id,
timeoutMs: context.defaultTimeoutMs, timeoutMs: context.defaultTimeoutMs,
type: "http", type: "http",
} satisfies ResolvedHttpTarget; } satisfies ResolvedHttpTarget;
@@ -154,10 +156,7 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
const method = init.method?.toUpperCase(); const method = init.method?.toUpperCase();
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) { if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
const headers = const headers = isObject(init.headers) ? { ...(init.headers as Record<string, string>) } : undefined;
typeof init.headers === "object" && init.headers !== null
? { ...(init.headers as Record<string, string>) }
: undefined;
if (headers) { if (headers) {
for (const key of Object.keys(headers)) { for (const key of Object.keys(headers)) {
const lower = key.toLowerCase(); const lower = key.toLowerCase();
@@ -172,7 +171,7 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
try { try {
const fromOrigin = new URL(fromUrl).origin; const fromOrigin = new URL(fromUrl).origin;
const toOrigin = new URL(toUrl).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<string, string>) }; const headers = { ...(newInit.headers as Record<string, string>) };
for (const key of Object.keys(headers)) { for (const key of Object.keys(headers)) {
if (SENSITIVE_HEADERS.has(key.toLowerCase())) { if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
@@ -264,7 +263,7 @@ function makeResult(
failure, failure,
matched: failure === null, matched: failure === null,
statusDetail: `HTTP ${statusCode}`, statusDetail: `HTTP ${statusCode}`,
targetName: t.name, targetId: t.id,
timestamp, timestamp,
}; };
} }

View File

@@ -1,3 +1,5 @@
import { isNumber, isString } from "es-toolkit";
import type { ExpectResult } from "../../expect/types"; import type { ExpectResult } from "../../expect/types";
import type { HeaderExpect } from "./types"; import type { HeaderExpect } from "./types";
@@ -14,7 +16,7 @@ export function checkHeaders(
const actualValue = headers[key.toLowerCase()]; const actualValue = headers[key.toLowerCase()];
const path = `headers.${key}`; const path = `headers.${key}`;
if (typeof expected === "string") { if (isString(expected)) {
if (actualValue !== expected) { if (actualValue !== expected) {
return { return {
failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`), failure: mismatchFailure("headers", path, expected, actualValue, `header ${key} mismatch`),
@@ -45,7 +47,7 @@ export function checkHeaders(
export function checkStatus(statusCode: number, allowed: Array<number | string>): ExpectResult { export function checkStatus(statusCode: number, allowed: Array<number | string>): ExpectResult {
const matched = allowed.some((pattern) => { 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; const base = parseInt(pattern[0]!, 10) * 100;
return statusCode >= base && statusCode < base + 100; return statusCode >= base && statusCode < base + 100;
}); });

View File

@@ -1,4 +1,6 @@
import { DOMParser } from "@xmldom/xmldom"; import { DOMParser } from "@xmldom/xmldom";
import { isNumber, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import * as xpath from "xpath"; import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../schema/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
@@ -14,7 +16,7 @@ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys); const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] { 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)); return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName));
} }
@@ -75,24 +77,25 @@ function collectOperatorObject(
} }
function getTargetName(target: Record<string, unknown>): string | undefined { function getTargetName(target: Record<string, unknown>): 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 { 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 { 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[] { function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = []; 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)); 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)); issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
} }
const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName); const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName);
@@ -112,7 +115,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
if (isPlainRecord(expect["headers"])) { if (isPlainRecord(expect["headers"])) {
for (const [key, value] of Object.entries(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)); issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
} }
} }
@@ -121,7 +124,7 @@ function validateHttpExpect(target: Record<string, unknown>, path: string): Conf
issues.push(...validateBodyRules(expect["body"], joinPath(expectPath, "body"), targetName)); 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)); issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
} }
@@ -141,7 +144,7 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
issues.push(...validateHttpExpect(target, path)); issues.push(...validateHttpExpect(target, path));
return issues; 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)); issues.push(issue("required", joinPath(joinPath(path, "http"), "url"), "缺少 http.url 字段", targetName));
} else { } else {
try { try {
@@ -172,7 +175,7 @@ function validateHttpTarget(target: Record<string, unknown>, path: string): Conf
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = []; const issues: ConfigValidationIssue[] = [];
if (typeof rule["path"] !== "string") { if (!isString(rule["path"])) {
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName)); issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
} else { } else {
issues.push(...validateJsonPath(rule["path"], path, targetName)); 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[] { 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 { try {
new RegExp(rule); new RegExp(rule);
} catch { } catch {
@@ -210,7 +213,7 @@ function validateSingleBodyRule(rule: unknown, path: string, targetName?: string
switch (ruleType) { switch (ruleType) {
case "contains": case "contains":
return typeof rule["contains"] === "string" return isString(rule["contains"])
? [] ? []
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)]; : [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
case "css": case "css":
@@ -238,13 +241,13 @@ function validateStatusValues(values: unknown[], path: string, targetName?: stri
for (let i = 0; i < values.length; i++) { for (let i = 0; i < values.length; i++) {
const value = values[i]; const value = values[i];
const itemPath = `${path}[${i}]`; const itemPath = `${path}[${i}]`;
if (typeof value === "number") { if (isNumber(value)) {
if (!Number.isInteger(value) || value < 100 || value > 599) { if (!Number.isInteger(value) || value < 100 || value > 599) {
issues.push(issue("invalid-status", itemPath, "status 数字必须为 100-599 之间的整数", targetName)); issues.push(issue("invalid-status", itemPath, "status 数字必须为 100-599 之间的整数", targetName));
} }
continue; continue;
} }
if (typeof value === "string") { if (isString(value)) {
if (!/^[1-5]xx$/.test(value)) { if (!/^[1-5]xx$/.test(value)) {
issues.push(issue("invalid-status", itemPath, "status 模式必须为 1xx 到 5xx", targetName)); 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[] { function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)]; if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
const issues: ConfigValidationIssue[] = []; 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)); issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
} else { } else {
try { try {

View File

@@ -4,7 +4,7 @@ import { Type } from "@sinclair/typebox";
import type { CheckerDefinition } from "../runner/types"; import type { CheckerDefinition } from "../runner/types";
import { durationSchema } from "./fragments"; import { durationSchema, variableValueSchema } from "./fragments";
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> { export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
return { return {
@@ -41,6 +41,7 @@ export function createProbeConfigSchema(checkers: CheckerDefinition[], external
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), { targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
minItems: 1, minItems: 1,
}), }),
variables: Type.Optional(Type.Record(Type.String({ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }), variableValueSchema)),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@@ -50,8 +51,9 @@ export function createTargetSchema(checker: CheckerDefinition): TSchema {
const properties: Record<string, TSchema> = { const properties: Record<string, TSchema> = {
expect: Type.Optional(checker.schemas.expect), expect: Type.Optional(checker.schemas.expect),
group: Type.Optional(Type.String()), group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }),
interval: Type.Optional(durationSchema), interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }), name: Type.Optional(Type.String({ minLength: 1 })),
timeout: Type.Optional(durationSchema), timeout: Type.Optional(durationSchema),
type: Type.Literal(checker.type), type: Type.Literal(checker.type),
}; };
@@ -67,8 +69,9 @@ function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
return Type.Object( return Type.Object(
{ {
group: Type.Optional(Type.String()), group: Type.Optional(Type.String()),
id: Type.String({ minLength: 1 }),
interval: Type.Optional(durationSchema), interval: Type.Optional(durationSchema),
name: Type.String({ minLength: 1 }), name: Type.Optional(Type.String({ minLength: 1 })),
timeout: Type.Optional(durationSchema), timeout: Type.Optional(durationSchema),
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]), type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
}, },

View File

@@ -29,6 +29,8 @@ export const jsonValueSchema = Type.Unsafe<JsonValue>({
export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]); 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([ export const statusCodePatternSchema = Type.Union([
Type.Integer({ maximum: 599, minimum: 100 }), Type.Integer({ maximum: 599, minimum: 100 }),
Type.String({ pattern: "^[1-5]xx$" }), Type.String({ pattern: "^[1-5]xx$" }),

View File

@@ -2,6 +2,7 @@ export interface ConfigValidationIssue {
code: string; code: string;
message: string; message: string;
path: string; path: string;
targetId?: string;
targetName?: string; targetName?: string;
} }
@@ -9,8 +10,20 @@ export function formatConfigIssues(issues: ConfigValidationIssue[]): string {
return issues.map(formatConfigIssue).join("\n"); return issues.map(formatConfigIssue).join("\n");
} }
export function issue(code: string, path: string, message: string, targetName?: string): ConfigValidationIssue { export function issue(
return targetName === undefined ? { code, message, path } : { code, message, path, targetName }; 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 { export function joinPath(base: string, key: string): string {
@@ -28,6 +41,15 @@ export function throwConfigIssues(issues: ConfigValidationIssue[]): never {
} }
function formatConfigIssue(issue: ConfigValidationIssue): string { 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) { if (issue.targetName) {
const path = issue.path.replace(/^targets\[\d+\]\.?/, ""); const path = issue.path.replace(/^targets\[\d+\]\.?/, "");
const renderedPath = path === "" ? "配置" : path; const renderedPath = path === "" ? "配置" : path;

View File

@@ -1,6 +1,8 @@
import type { ErrorObject } from "ajv"; import type { ErrorObject } from "ajv";
import Ajv 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 { CheckerRegistry } from "../runner/registry";
import type { ConfigValidationIssue } from "./issues"; import type { ConfigValidationIssue } from "./issues";
@@ -29,12 +31,19 @@ export function validateProbeConfigContract(
issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config)); issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config));
} }
if (isRecord(config) && isUnknownArray(config["targets"])) { if (isPlainObject(config)) {
const targets = config["targets"]; const configRecord = config as Record<string, unknown>;
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++) { for (let i = 0; i < targets.length; i++) {
const target = targets[i]; const target: unknown = targets[i];
if (!isRecord(target) || typeof target["type"] !== "string") continue; if (!isPlainObject(target)) continue;
const checker = registry.tryGet(target["type"]); const targetRecord = target as Record<string, unknown>;
const targetType: unknown = targetRecord["type"];
if (!isString(targetType)) continue;
const checker = registry.tryGet(targetType);
if (!checker) continue; if (!checker) continue;
const targetValidate = ajv.compile(createTargetSchema(checker)); const targetValidate = ajv.compile(createTargetSchema(checker));
if (!targetValidate(target)) { if (!targetValidate(target)) {
@@ -62,13 +71,9 @@ function hasMoreSpecificError(keywords: Set<string>): boolean {
return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword)); return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword));
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string): ConfigValidationIssue { function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string): ConfigValidationIssue {
const path = buildIssuePath(basePath, error); const path = buildIssuePath(basePath, error);
const targetName = targetNameFromPath(root, path); const targetName = targetDisplayNameFromPath(root, path);
switch (error.keyword) { switch (error.keyword) {
case "additionalProperties": case "additionalProperties":
return issue("unknown-field", path, "是未知字段", targetName); 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 { function joinBasePath(basePath: string, path: string): string {
if (basePath === "") return path; if (basePath === "") return path;
if (path === "") return basePath; 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); const match = /^targets\[(\d+)\]/.exec(path);
if (!match || !isRecord(root) || !isUnknownArray(root["targets"])) return undefined; if (!match || !isPlainObject(root)) return undefined;
const target = root["targets"][Number(match[1])]; const rootRecord = root as Record<string, unknown>;
if (!isRecord(target) || typeof target["name"] !== "string") return undefined; const targetsValue: unknown = rootRecord["targets"];
return target["name"]; if (!isArray(targetsValue)) return undefined;
const target: unknown = targetsValue[Number(match[1])];
if (!isPlainObject(target)) return undefined;
const targetRecord = target as Record<string, unknown>;
const targetName: unknown = targetRecord["name"];
if (isString(targetName)) return targetName;
const targetId: unknown = targetRecord["id"];
return isString(targetId) ? targetId : undefined;
} }

View File

@@ -8,8 +8,8 @@ import { checkerRegistry } from "./runner";
const CREATE_TARGETS_TABLE = ` const CREATE_TARGETS_TABLE = `
CREATE TABLE IF NOT EXISTS targets ( CREATE TABLE IF NOT EXISTS targets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
target TEXT NOT NULL, target TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}', config TEXT NOT NULL DEFAULT '{}',
@@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS targets (
const CREATE_CHECK_RESULTS_TABLE = ` const CREATE_CHECK_RESULTS_TABLE = `
CREATE TABLE IF NOT EXISTS check_results ( CREATE TABLE IF NOT EXISTS check_results (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL, target_id TEXT NOT NULL,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
matched INTEGER NOT NULL, matched INTEGER NOT NULL,
duration_ms REAL, duration_ms REAL,
@@ -59,7 +59,7 @@ export class ProbeStore {
getAllRecentSamples( getAllRecentSamples(
limit: number, limit: number,
): Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> { ): Map<string, Array<{ duration_ms: null | number; matched: number; timestamp: string }>> {
if (this.closed) return new Map(); if (this.closed) return new Map();
const rows = this.db const rows = this.db
@@ -80,11 +80,11 @@ export class ProbeStore {
.all(limit) as Array<{ .all(limit) as Array<{
duration_ms: null | number; duration_ms: null | number;
matched: number; matched: number;
target_id: number; target_id: string;
timestamp: string; timestamp: string;
}>; }>;
const result = new Map<number, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>(); const result = new Map<string, Array<{ duration_ms: null | number; matched: number; timestamp: string }>>();
for (const row of rows) { for (const row of rows) {
const samples = result.get(row.target_id) ?? []; const samples = result.get(row.target_id) ?? [];
samples.push({ duration_ms: row.duration_ms, matched: row.matched, timestamp: row.timestamp }); samples.push({ duration_ms: row.duration_ms, matched: row.matched, timestamp: row.timestamp });
@@ -96,7 +96,7 @@ export class ProbeStore {
getAllTargetWindowStats( getAllTargetWindowStats(
from: string, from: string,
to: string, to: string,
): Map<number, { availability: number; downChecks: number; totalChecks: number; upChecks: number }> { ): Map<string, { availability: number; downChecks: number; totalChecks: number; upChecks: number }> {
if (this.closed) return new Map(); if (this.closed) return new Map();
const rows = this.db const rows = this.db
@@ -108,10 +108,10 @@ export class ProbeStore {
WHERE timestamp >= ? AND timestamp <= ? WHERE timestamp >= ? AND timestamp <= ?
GROUP BY target_id`, 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< const result = new Map<
number, string,
{ availability: number; downChecks: number; totalChecks: number; upChecks: number } { availability: number; downChecks: number; totalChecks: number; upChecks: number }
>(); >();
for (const row of rows) { for (const row of rows) {
@@ -129,7 +129,7 @@ export class ProbeStore {
getDashboardIncidentStates( getDashboardIncidentStates(
from: string, from: string,
to: string, to: string,
): Array<{ matched: number; target_id: number; timestamp: string }> { ): Array<{ matched: number; target_id: string; timestamp: string }> {
if (this.closed) return []; if (this.closed) return [];
return this.db return this.db
@@ -139,11 +139,11 @@ export class ProbeStore {
WHERE timestamp >= ? AND timestamp <= ? WHERE timestamp >= ? AND timestamp <= ?
ORDER BY target_id ASC, timestamp ASC`, 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( getHistory(
targetId: number, targetId: string,
from: string, from: string,
to: string, to: string,
page = 1, page = 1,
@@ -163,13 +163,13 @@ export class ProbeStore {
return { items, page, pageSize, total: countRow.total }; return { items, page, pageSize, total: countRow.total };
} }
getLatestCheck(targetId: number): null | StoredCheckResult { getLatestCheck(targetId: string): null | StoredCheckResult {
return this.db return this.db
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1") .query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
.get(targetId) as null | StoredCheckResult; .get(targetId) as null | StoredCheckResult;
} }
getLatestChecksMap(): Map<number, StoredCheckResult> { getLatestChecksMap(): Map<string, StoredCheckResult> {
const rows = this.db const rows = this.db
.query( .query(
`SELECT cr.* FROM check_results cr `SELECT cr.* FROM check_results cr
@@ -184,7 +184,7 @@ export class ProbeStore {
} }
getRecentSamples( getRecentSamples(
targetId: number, targetId: string,
limit: number, limit: number,
): Array<{ duration_ms: null | number; matched: number; timestamp: string }> { ): Array<{ duration_ms: null | number; matched: number; timestamp: string }> {
return this.db 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; if (this.closed) return null;
return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget; return this.db.query("SELECT * FROM targets WHERE id = ?").get(id) as null | StoredTarget;
} }
getTargetCheckpoints( getTargetCheckpoints(
targetId: number, targetId: string,
from: string, from: string,
to: string, to: string,
): Array<{ duration_ms: null | number; matched: number; timestamp: 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 }>; .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 []; if (this.closed) return [];
const rows = this.db const rows = this.db
@@ -243,7 +243,7 @@ export class ProbeStore {
} }
getTargetWindowStats( getTargetWindowStats(
targetId: number, targetId: string,
from: string, from: string,
to: string, to: string,
): { ): {
@@ -281,7 +281,7 @@ export class ProbeStore {
failure: CheckFailure | null; failure: CheckFailure | null;
matched: boolean; matched: boolean;
statusDetail: null | string; statusDetail: null | string;
targetId: number; targetId: string;
timestamp: string; timestamp: string;
}): void { }): void {
if (this.closed) return; if (this.closed) return;
@@ -308,18 +308,18 @@ export class ProbeStore {
syncTargets(targets: ResolvedTargetBase[]): void { syncTargets(targets: ResolvedTargetBase[]): void {
if (this.closed) return; if (this.closed) return;
const existingRows = this.db.query("SELECT id, name FROM targets").all() as Array<{ const existingRows = this.db.query("SELECT id FROM targets").all() as Array<{
id: number; id: string;
name: string; name: string;
}>; }>;
const existingMap = new Map(existingRows.map((r) => [r.name, r.id])); const existingIds = new Set(existingRows.map((r) => r.id));
const configNames = new Set(targets.map((t) => t.name)); const configIds = new Set(targets.map((t) => t.id));
const insertStmt = this.db.prepare( 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( 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 = ?"); const deleteStmt = this.db.prepare("DELETE FROM targets WHERE id = ?");
@@ -331,15 +331,15 @@ export class ProbeStore {
const config = serialized.config; const config = serialized.config;
const expect = t.expect ? JSON.stringify(t.expect) : null; const expect = t.expect ? JSON.stringify(t.expect) : null;
if (existingMap.has(t.name)) { if (existingIds.has(t.id)) {
updateStmt.run(type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, existingMap.get(t.name)!); updateStmt.run(t.name, type, target, config, t.intervalMs, t.timeoutMs, expect, t.group, t.id);
} else { } 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) { for (const id of existingIds) {
if (!configNames.has(name)) { if (!configIds.has(id)) {
deleteStmt.run(id); deleteStmt.run(id);
} }
} }

View File

@@ -1,7 +1,7 @@
import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api"; import type { CheckResult as ApiCheckResult, CheckFailure } from "../../shared/api";
export interface CheckResult extends ApiCheckResult { export interface CheckResult extends ApiCheckResult {
targetName: string; targetId: string;
} }
export interface DefaultsConfig { export interface DefaultsConfig {
@@ -36,14 +36,16 @@ export interface ProbeConfig {
runtime?: EngineRuntimeConfig; runtime?: EngineRuntimeConfig;
server?: ServerConfig; server?: ServerConfig;
targets: RawTargetConfig[]; targets: RawTargetConfig[];
variables?: Record<string, VariableValue>;
} }
export interface RawTargetConfig { export interface RawTargetConfig {
[configKey: string]: unknown; [configKey: string]: unknown;
expect?: unknown; expect?: unknown;
group?: string; group?: string;
id: string;
interval?: string; interval?: string;
name: string; name?: string;
timeout?: string; timeout?: string;
type: string; type: string;
} }
@@ -52,6 +54,7 @@ export interface ResolvedTargetBase {
[key: string]: unknown; [key: string]: unknown;
expect?: unknown; expect?: unknown;
group: string; group: string;
id: string;
intervalMs: number; intervalMs: number;
name: string; name: string;
timeoutMs: number; timeoutMs: number;
@@ -70,7 +73,7 @@ export interface StoredCheckResult {
id: number; id: number;
matched: number; matched: number;
status_detail: null | string; status_detail: null | string;
target_id: number; target_id: string;
timestamp: string; timestamp: string;
} }
@@ -78,7 +81,7 @@ export interface StoredTarget {
config: string; config: string;
expect: null | string; expect: null | string;
grp: string; grp: string;
id: number; id: string;
interval_ms: number; interval_ms: number;
name: string; name: string;
target: string; target: string;
@@ -86,4 +89,6 @@ export interface StoredTarget {
type: string; type: string;
} }
export type VariableValue = boolean | number | string;
export type { CheckFailure }; export type { CheckFailure };

View File

@@ -1,3 +1,5 @@
import { isNumber } from "es-toolkit";
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/; const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/; 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 { export function parseSize(value: number | string): number {
if (typeof value === "number") { if (isNumber(value)) {
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) { if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`); throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`);
} }

View File

@@ -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<string, VariableValue>;
} {
const issues: ConfigValidationIssue[] = [];
const variables = new Map<string, VariableValue>();
if (!isPlainObject(config)) {
return { issues, variables };
}
const configRecord = config as Record<string, unknown>;
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<string, unknown>)) {
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<string, unknown>;
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<string, VariableValue>,
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<string, VariableValue>,
issues: ConfigValidationIssue[],
): unknown {
if (!isPlainObject(target)) return target;
const targetRecord = target as Record<string, unknown>;
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<string, VariableValue>,
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<string, unknown> = {};
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<string, VariableValue>,
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;
}

View File

@@ -69,12 +69,11 @@ export function validateRecentLimit(limitParam: null | string, mode: RuntimeMode
return { recentLimit }; return { recentLimit };
} }
export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: number } { export function validateTargetId(idStr: string, mode: RuntimeMode): Response | { id: string } {
const id = Number(idStr); if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(idStr)) {
if (!Number.isInteger(id) || id <= 0) {
return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 }); return jsonResponse(createApiError("Invalid target ID", 400), { mode, status: 400 });
} }
return { id }; return { id: idStr };
} }
export function validateTimeRange( export function validateTimeRange(

View File

@@ -88,9 +88,9 @@ export function handleDashboard(url: URL, store: ProbeStore, mode: RuntimeMode):
} }
function groupDashboardIncidentStates( function groupDashboardIncidentStates(
states: Array<{ matched: number; target_id: number; timestamp: string }>, states: Array<{ matched: number; target_id: string; timestamp: string }>,
): Map<number, MetricCheckpoint[]> { ): Map<string, MetricCheckpoint[]> {
const result = new Map<number, MetricCheckpoint[]>(); const result = new Map<string, MetricCheckpoint[]>();
for (const state of states) { for (const state of states) {
const list = result.get(state.target_id) ?? []; const list = result.get(state.target_id) ?? [];
list.push({ durationMs: null, matched: state.matched === 1, timestamp: state.timestamp }); list.push({ durationMs: null, matched: state.matched === 1, timestamp: state.timestamp });

View File

@@ -81,7 +81,7 @@ export interface TargetMetricsResponse {
totalChecks: number; totalChecks: number;
upChecks: number; upChecks: number;
}; };
targetId: number; targetId: string;
trend: TrendPoint[]; trend: TrendPoint[];
window: { window: {
bucket: "1h"; bucket: "1h";
@@ -100,7 +100,7 @@ export interface TargetStats {
export interface TargetStatus { export interface TargetStatus {
currentStreak: CurrentStreak | null; currentStreak: CurrentStreak | null;
group: string; group: string;
id: number; id: string;
interval: string; interval: string;
latestCheck: CheckResult | null; latestCheck: CheckResult | null;
name: string; name: string;

View File

@@ -5,7 +5,7 @@ import type { DashboardResponse, MetaResponse, TargetMetricsResponse } from "../
const queryKeys = { const queryKeys = {
dashboard: () => ["dashboard", "24h", 30] as const, dashboard: () => ["dashboard", "24h", 30] as const,
meta: () => ["meta"] 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, ["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({ return useQuery({
enabled: targetId !== null && !!from && !!to, enabled: targetId !== null && !!from && !!to,
queryFn: () => { queryFn: () => {

View File

@@ -7,11 +7,11 @@ import { subtractHours } from "../utils/time";
import { fetchJson, useDashboard, useTargetMetrics } from "./use-queries"; import { fetchJson, useDashboard, useTargetMetrics } from "./use-queries";
const detailQueryKeys = { 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() { export function useTargetDetail() {
const [selectedTargetId, setSelectedTargetId] = useState<null | number>(null); const [selectedTargetId, setSelectedTargetId] = useState<null | string>(null);
const [timeFrom, setTimeFrom] = useState(""); const [timeFrom, setTimeFrom] = useState("");
const [timeTo, setTimeTo] = useState(""); const [timeTo, setTimeTo] = useState("");
const [historyPage, setHistoryPage] = useState(1); const [historyPage, setHistoryPage] = useState(1);

View File

@@ -50,6 +50,7 @@ describe("API 路由", () => {
method: "GET", method: "GET",
url: "http://a.com", url: "http://a.com",
}, },
id: "test-a",
intervalMs: 30000, intervalMs: 30000,
name: "test-a", name: "test-a",
timeoutMs: 10000, timeoutMs: 10000,
@@ -64,6 +65,7 @@ describe("API 路由", () => {
maxOutputBytes: 104857600, maxOutputBytes: 104857600,
}, },
group: "default", group: "default",
id: "test-b",
intervalMs: 60000, intervalMs: 60000,
name: "test-b", name: "test-b",
timeoutMs: 5000, timeoutMs: 5000,
@@ -204,6 +206,7 @@ describe("API 路由", () => {
expect(body.targets).toHaveLength(2); expect(body.targets).toHaveLength(2);
const tA = body.targets.find((t) => t.name === "test-a")!; const tA = body.targets.find((t) => t.name === "test-a")!;
expect(tA.id).toBe("test-a");
expect(tA.type).toBe("http"); expect(tA.type).toBe("http");
expect(tA.target).toBe("http://a.com"); expect(tA.target).toBe("http://a.com");
expect(tA.group).toBe("default"); expect(tA.group).toBe("default");
@@ -372,9 +375,9 @@ describe("API 路由", () => {
expect(body["error"]).toContain("from and to"); expect(body["error"]).toContain("from and to");
}); });
test("metrics 无效 targetId 返回 400", async () => { test("metrics 无效 target id 返回 400", async () => {
const response = await fetch( 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<string, unknown>; const body = (await response.json()) as Record<string, unknown>;

View File

@@ -12,6 +12,7 @@ type ShutdownSignal = "SIGINT" | "SIGTERM";
const target: ResolvedTargetBase = { const target: ResolvedTargetBase = {
group: "default", group: "default",
id: "test",
intervalMs: 30000, intervalMs: 30000,
name: "test", name: "test",
timeoutMs: 5000, timeoutMs: 5000,

View File

@@ -28,6 +28,7 @@ describe("config contract", () => {
targets: [ targets: [
{ {
http: { method: "get", unknownHttpField: true, url: "https://example.com" }, http: { method: "get", unknownHttpField: true, url: "https://example.com" },
id: "api",
name: "api", name: "api",
type: "http", type: "http",
}, },
@@ -36,6 +37,31 @@ describe("config contract", () => {
).toBe(false); ).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", () => { test("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract( const result = validateProbeConfigContract(
{ {
@@ -43,6 +69,7 @@ describe("config contract", () => {
{ {
group: 123, group: 123,
http: { extra: true }, http: { extra: true },
id: "api",
name: "api", name: "api",
type: "http", type: "http",
}, },

View File

@@ -107,6 +107,7 @@ describe("loadConfig", () => {
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -121,6 +122,7 @@ describe("loadConfig", () => {
expect(config.targets).toHaveLength(1); expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedHttpTarget; const t = config.targets[0]! as ResolvedHttpTarget;
expect(t.type).toBe("http"); expect(t.type).toBe("http");
expect(t.id).toBe("test");
expect(t.name).toBe("test"); expect(t.name).toBe("test");
expect(t.http.url).toBe("http://example.com"); expect(t.http.url).toBe("http://example.com");
expect(t.http.method).toBe("GET"); expect(t.http.method).toBe("GET");
@@ -140,6 +142,7 @@ describe("loadConfig", () => {
configPath, configPath,
`targets: `targets:
- name: "check-nginx" - name: "check-nginx"
id: "check-nginx"
type: cmd type: cmd
cmd: cmd:
exec: "pgrep" exec: "pgrep"
@@ -151,6 +154,7 @@ describe("loadConfig", () => {
expect(config.targets).toHaveLength(1); expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedCommandTarget; const t = config.targets[0]! as ResolvedCommandTarget;
expect(t.type).toBe("cmd"); expect(t.type).toBe("cmd");
expect(t.id).toBe("check-nginx");
expect(t.name).toBe("check-nginx"); expect(t.name).toBe("check-nginx");
expect(t.cmd.exec).toBe("pgrep"); expect(t.cmd.exec).toBe("pgrep");
expect(t.cmd.args).toEqual(["nginx"]); expect(t.cmd.args).toEqual(["nginx"]);
@@ -181,6 +185,7 @@ defaults:
maxOutputBytes: "10MB" maxOutputBytes: "10MB"
targets: targets:
- name: "http-target" - name: "http-target"
id: "http-target"
type: http type: http
interval: "1m" interval: "1m"
http: http:
@@ -193,6 +198,7 @@ targets:
body: body:
- contains: "ok" - contains: "ok"
- name: "cmd-target" - name: "cmd-target"
id: "cmd-target"
type: cmd type: cmd
cmd: cmd:
exec: "ls" exec: "ls"
@@ -228,6 +234,140 @@ targets:
expect(cmd.cmd.maxOutputBytes).toBe(10485760); 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 () => { test("绝对 dataDir 保持不变", async () => {
const dataDir = join(tempDir, "absolute-data"); const dataDir = join(tempDir, "absolute-data");
const configPath = join(tempDir, "absolute-data-dir.yaml"); const configPath = join(tempDir, "absolute-data-dir.yaml");
@@ -237,6 +377,7 @@ targets:
dataDir: ${JSON.stringify(dataDir)} dataDir: ${JSON.stringify(dataDir)}
targets: targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -258,6 +399,7 @@ targets:
maxBodyBytes: "10MB" maxBodyBytes: "10MB"
targets: targets:
- name: "override-all" - name: "override-all"
id: "override-all"
type: http type: http
interval: "5m" interval: "5m"
timeout: "30s" timeout: "30s"
@@ -281,8 +423,8 @@ targets:
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在"); await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
}); });
test("target 缺少 name 抛出错误", async () => { test("target 缺少 id 抛出错误", async () => {
const configPath = join(tempDir, "no-name.yaml"); const configPath = join(tempDir, "no-id.yaml");
await writeFile( await writeFile(
configPath, configPath,
`targets: `targets:
@@ -292,7 +434,7 @@ targets:
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable // 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 () => { test("target 缺少 type 抛出错误", async () => {
@@ -301,6 +443,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
http: http:
url: "http://example.com" url: "http://example.com"
`, `,
@@ -315,6 +458,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: {} http: {}
`, `,
@@ -329,6 +473,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
`, `,
); );
@@ -342,6 +487,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -358,6 +504,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -374,6 +521,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -391,6 +539,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: cmd type: cmd
cmd: {} cmd: {}
`, `,
@@ -405,6 +554,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: dns type: dns
`, `,
); );
@@ -412,23 +562,70 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type"); await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
}); });
test("target name 重复抛出错误", async () => { test("target id 重复抛出错误", async () => {
const configPath = join(tempDir, "dup-name.yaml"); const configPath = join(tempDir, "dup-id.yaml");
await writeFile( await writeFile(
configPath, configPath,
`targets: `targets:
- name: "dup" - name: "dup"
id: "dup"
type: http type: http
http: http:
url: "http://a.com" url: "http://a.com"
- name: "dup" - name: "dup"
id: "dup"
type: http type: http
http: http:
url: "http://b.com" url: "http://b.com"
`, `,
); );
// eslint-disable-next-line @typescript-eslint/await-thenable // 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 () => { test("targets 为空数组抛出错误", async () => {
@@ -446,6 +643,7 @@ targets:
port: 99999 port: 99999
targets: targets:
- name: "t" - name: "t"
id: "t"
type: http type: http
http: http:
url: "http://a.com" url: "http://a.com"
@@ -463,6 +661,7 @@ targets:
maxConcurrentChecks: -1 maxConcurrentChecks: -1
targets: targets:
- name: "t" - name: "t"
id: "t"
type: http type: http
http: http:
url: "http://a.com" url: "http://a.com"
@@ -481,6 +680,7 @@ targets:
maxBodyBytes: "100TB" maxBodyBytes: "100TB"
targets: targets:
- name: "t" - name: "t"
id: "t"
type: http type: http
http: http:
url: "http://a.com" url: "http://a.com"
@@ -496,6 +696,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "t" - name: "t"
id: "t"
type: http type: http
interval: "30x" interval: "30x"
http: http:
@@ -512,6 +713,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "with-expect" - name: "with-expect"
id: "with-expect"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -543,6 +745,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "cmd-with-expect" - name: "cmd-with-expect"
id: "cmd-with-expect"
type: cmd type: cmd
cmd: cmd:
exec: "mycheck" exec: "mycheck"
@@ -577,6 +780,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "cwd-test" - name: "cwd-test"
id: "cwd-test"
type: cmd type: cmd
cmd: cmd:
exec: "ls" exec: "ls"
@@ -595,6 +799,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "env-test" - name: "env-test"
id: "env-test"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -617,6 +822,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "grouped" - name: "grouped"
id: "grouped"
type: http type: http
group: "搜索引擎" group: "搜索引擎"
http: http:
@@ -634,6 +840,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "no-group" - name: "no-group"
id: "no-group"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -650,6 +857,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
group: 123 group: 123
http: http:
@@ -667,6 +875,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -684,6 +893,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -700,6 +910,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -716,6 +927,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -732,6 +944,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -749,6 +962,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -766,6 +980,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -783,6 +998,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -800,6 +1016,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -818,6 +1035,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -836,6 +1054,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -855,6 +1074,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -873,6 +1093,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -893,6 +1114,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -912,6 +1134,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -931,6 +1154,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -951,6 +1175,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -971,6 +1196,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -993,6 +1219,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -1012,6 +1239,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -1028,6 +1256,7 @@ targets:
"lowercase-method.yaml", "lowercase-method.yaml",
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -1045,6 +1274,7 @@ targets:
method: POST method: POST
targets: targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -1059,6 +1289,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -1081,6 +1312,7 @@ targets:
X-Default-Header: "default" X-Default-Header: "default"
targets: targets:
- name: "http-test" - name: "http-test"
id: "http-test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -1091,6 +1323,7 @@ targets:
X-Response-Header: X-Response-Header:
contains: "ok" contains: "ok"
- name: "cmd-test" - name: "cmd-test"
id: "cmd-test"
type: cmd type: cmd
cmd: cmd:
exec: "true" exec: "true"
@@ -1113,6 +1346,7 @@ targets:
"bad-cmd-args.yaml", "bad-cmd-args.yaml",
`targets: `targets:
- name: "cmd" - name: "cmd"
id: "cmd"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -1127,6 +1361,7 @@ targets:
"bad-cmd-cwd.yaml", "bad-cmd-cwd.yaml",
`targets: `targets:
- name: "cmd" - name: "cmd"
id: "cmd"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -1141,6 +1376,7 @@ targets:
"bad-cmd-env.yaml", "bad-cmd-env.yaml",
`targets: `targets:
- name: "cmd" - name: "cmd"
id: "cmd"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -1156,6 +1392,7 @@ targets:
"bad-cmd-max-output.yaml", "bad-cmd-max-output.yaml",
`targets: `targets:
- name: "cmd" - name: "cmd"
id: "cmd"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -1170,6 +1407,7 @@ targets:
"bad-cmd-exit-code.yaml", "bad-cmd-exit-code.yaml",
`targets: `targets:
- name: "cmd" - name: "cmd"
id: "cmd"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -1185,6 +1423,7 @@ targets:
"bad-cmd-stdout-empty.yaml", "bad-cmd-stdout-empty.yaml",
`targets: `targets:
- name: "cmd" - name: "cmd"
id: "cmd"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -1201,6 +1440,7 @@ targets:
"bad-cmd-stderr-operator.yaml", "bad-cmd-stderr-operator.yaml",
`targets: `targets:
- name: "cmd" - name: "cmd"
id: "cmd"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -1217,6 +1457,7 @@ targets:
"bad-cmd-stdout-regex.yaml", "bad-cmd-stdout-regex.yaml",
`targets: `targets:
- name: "cmd" - name: "cmd"
id: "cmd"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -1233,6 +1474,7 @@ targets:
"bad-cmd-expect-unknown.yaml", "bad-cmd-expect-unknown.yaml",
`targets: `targets:
- name: "cmd" - name: "cmd"
id: "cmd"
type: cmd type: cmd
cmd: cmd:
exec: "echo" exec: "echo"
@@ -1249,6 +1491,7 @@ targets:
configPath, configPath,
`targets: `targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -1266,6 +1509,7 @@ targets:
retention: "24h" retention: "24h"
targets: targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"
@@ -1282,6 +1526,7 @@ targets:
retention: "7x" retention: "7x"
targets: targets:
- name: "test" - name: "test"
id: "test"
type: http type: http
http: http:
url: "http://example.com" url: "http://example.com"

View File

@@ -15,8 +15,7 @@ const processEnv = Object.fromEntries(
); );
function createMockStore(targetNames: string[]) { function createMockStore(targetNames: string[]) {
let nextId = 1; const targets = targetNames.map((name) => ({ id: name, name }));
const targets = targetNames.map((name) => ({ id: nextId++, name }));
const results: Array<Record<string, unknown>> = []; const results: Array<Record<string, unknown>> = [];
return { return {
@@ -57,6 +56,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
maxOutputBytes: 1024 * 1024, maxOutputBytes: 1024 * 1024,
}, },
group: "default", group: "default",
id: name,
intervalMs: 60000, intervalMs: 60000,
name, name,
timeoutMs: 5000, timeoutMs: 5000,
@@ -177,7 +177,7 @@ describe("ProbeEngine", () => {
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results; const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2); 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]!["matched"]).toBe(false);
expect(results[0]!["durationMs"]).toBeNull(); expect(results[0]!["durationMs"]).toBeNull();
expect(results[0]!["statusDetail"]).toBeNull(); expect(results[0]!["statusDetail"]).toBeNull();
@@ -188,7 +188,7 @@ describe("ProbeEngine", () => {
phase: "internal", phase: "internal",
}); });
expect(typeof results[0]!["timestamp"]).toBe("string"); 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); expect(results[1]!["matched"]).toBe(true);
} finally { } finally {
checker.execute = originalExecute; checker.execute = originalExecute;
@@ -235,7 +235,7 @@ describe("ProbeEngine", () => {
expect(true).toBe(true); expect(true).toBe(true);
}); });
test("未注册的 targetName 不写入结果", async () => { test("未注册的 target id 不写入结果", async () => {
const target = makeCommandTarget("unknown-target"); const target = makeCommandTarget("unknown-target");
const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore; const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]); const engine = new ProbeEngine(mockStore, [target]);
@@ -268,6 +268,7 @@ describe("ProbeEngine", () => {
method: "GET", method: "GET",
url: `http://localhost:${httpServer.port}/`, url: `http://localhost:${httpServer.port}/`,
}, },
id: "http-test",
intervalMs: 60000, intervalMs: 60000,
name: "http-test", name: "http-test",
timeoutMs: 5000, timeoutMs: 5000,

View File

@@ -31,6 +31,7 @@ function makeTarget(
...cmd, ...cmd,
}, },
group: "default", group: "default",
id: "test-cmd",
intervalMs: 60000, intervalMs: 60000,
name: "test-cmd", name: "test-cmd",
timeoutMs: 5000, timeoutMs: 5000,

View File

@@ -20,6 +20,7 @@ function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<Res
...db, ...db,
}, },
group: "default", group: "default",
id: "test-db",
intervalMs: 60000, intervalMs: 60000,
name: "test-db", name: "test-db",
timeoutMs: 5000, timeoutMs: 5000,

View File

@@ -11,7 +11,7 @@ describe("validateDbConfig", () => {
test("缺少 db.url 返回错误", () => { test("缺少 db.url 返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, defaults: {},
targets: [{ name: "test", type: "db" }], targets: [{ id: "test", name: "test", type: "db" }],
}); });
expect(result.length).toBeGreaterThan(0); expect(result.length).toBeGreaterThan(0);
const dbError = result.find((e) => e.path.includes("db")); const dbError = result.find((e) => e.path.includes("db"));
@@ -22,7 +22,7 @@ describe("validateDbConfig", () => {
test("db.url 为空字符串返回错误", () => { test("db.url 为空字符串返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, 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")); const urlError = result.find((e) => e.path.includes("db.url"));
expect(urlError).toBeDefined(); expect(urlError).toBeDefined();
@@ -32,7 +32,7 @@ describe("validateDbConfig", () => {
test("db.query 为空字符串返回错误", () => { test("db.query 为空字符串返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, 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")); const queryError = result.find((e) => e.path.includes("db.query"));
expect(queryError).toBeDefined(); expect(queryError).toBeDefined();
@@ -42,7 +42,7 @@ describe("validateDbConfig", () => {
test("db 分组未知字段返回错误", () => { test("db 分组未知字段返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, 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")); const unknownError = result.find((e) => e.path.includes("db.timeout"));
expect(unknownError).toBeDefined(); expect(unknownError).toBeDefined();
@@ -52,7 +52,15 @@ describe("validateDbConfig", () => {
test("expect.maxDurationMs 非数字返回错误", () => { test("expect.maxDurationMs 非数字返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, 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")); const durationError = result.find((e) => e.path.includes("expect.maxDurationMs"));
expect(durationError).toBeDefined(); expect(durationError).toBeDefined();
@@ -62,7 +70,9 @@ describe("validateDbConfig", () => {
test("expect.rowCount 非法 operator 返回错误", () => { test("expect.rowCount 非法 operator 返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, 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")); const rowCountError = result.find((e) => e.path.includes("expect.rowCount"));
expect(rowCountError).toBeDefined(); expect(rowCountError).toBeDefined();
@@ -72,7 +82,9 @@ describe("validateDbConfig", () => {
test("expect.rows 不是数组返回错误", () => { test("expect.rows 不是数组返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, 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")); const rowsError = result.find((e) => e.path.includes("expect.rows"));
expect(rowsError).toBeDefined(); expect(rowsError).toBeDefined();
@@ -82,7 +94,9 @@ describe("validateDbConfig", () => {
test("expect.rows 元素不是对象返回错误", () => { test("expect.rows 元素不是对象返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, 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]")); const rowError = result.find((e) => e.path.includes("expect.rows[0]"));
expect(rowError).toBeDefined(); expect(rowError).toBeDefined();
@@ -96,6 +110,7 @@ describe("validateDbConfig", () => {
{ {
db: { url: "sqlite://:memory:" }, db: { url: "sqlite://:memory:" },
expect: { rows: [{ name: { match: "[invalid" } }] }, expect: { rows: [{ name: { match: "[invalid" } }] },
id: "test",
name: "test", name: "test",
type: "db", type: "db",
}, },
@@ -109,7 +124,7 @@ describe("validateDbConfig", () => {
test("expect 未知字段返回错误", () => { test("expect 未知字段返回错误", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, 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")); const unknownError = result.find((e) => e.path.includes("expect.status"));
expect(unknownError).toBeDefined(); expect(unknownError).toBeDefined();
@@ -123,6 +138,7 @@ describe("validateDbConfig", () => {
{ {
db: { query: "SELECT 1", url: "sqlite://:memory:" }, db: { query: "SELECT 1", url: "sqlite://:memory:" },
expect: { maxDurationMs: 5000, rowCount: { gte: 1 }, rows: [{ cnt: { gte: 1 } }] }, expect: { maxDurationMs: 5000, rowCount: { gte: 1 }, rows: [{ cnt: { gte: 1 } }] },
id: "test",
name: "test", name: "test",
type: "db", type: "db",
}, },
@@ -134,7 +150,7 @@ describe("validateDbConfig", () => {
test("忽略非 db 类型 target", () => { test("忽略非 db 类型 target", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, defaults: {},
targets: [{ name: "test", type: "http" }], targets: [{ id: "test", name: "test", type: "http" }],
}); });
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
@@ -143,8 +159,8 @@ describe("validateDbConfig", () => {
const result = validateDbConfig({ const result = validateDbConfig({
defaults: {}, defaults: {},
targets: [ targets: [
{ db: { url: "sqlite://:memory:" }, name: "db1", type: "db" }, { db: { url: "sqlite://:memory:" }, id: "db1", name: "db1", type: "db" },
{ db: { url: "" }, name: "db2", type: "db" }, { db: { url: "" }, id: "db2", name: "db2", type: "db" },
], ],
}); });
expect(result.length).toBeGreaterThan(0); expect(result.length).toBeGreaterThan(0);

View File

@@ -166,6 +166,7 @@ describe("HttpChecker", () => {
method: overrides.method ?? "GET", method: overrides.method ?? "GET",
url: overrides.url ?? `${baseUrl}/ok`, url: overrides.url ?? `${baseUrl}/ok`,
}, },
id: "test-http",
intervalMs: 60000, intervalMs: 60000,
name: "test-http", name: "test-http",
timeoutMs: overrides.timeoutMs ?? 5000, timeoutMs: overrides.timeoutMs ?? 5000,
@@ -850,6 +851,7 @@ describe("HttpChecker.resolve", () => {
const errors = validateHttpTarget({ const errors = validateHttpTarget({
expect: { status: ["abc"] }, expect: { status: ["abc"] },
http: { url: "https://example.com" }, http: { url: "https://example.com" },
id: "test",
name: "test", name: "test",
type: "http", type: "http",
}); });
@@ -858,7 +860,7 @@ describe("HttpChecker.resolve", () => {
test("ignoreSSL 默认值为 false", () => { test("ignoreSSL 默认值为 false", () => {
const result = checker.resolve( 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(), makeResolveContext(),
); );
expect(result.http.ignoreSSL).toBe(false); expect(result.http.ignoreSSL).toBe(false);
@@ -866,7 +868,7 @@ describe("HttpChecker.resolve", () => {
test("maxRedirects 默认值为 0", () => { test("maxRedirects 默认值为 0", () => {
const result = checker.resolve( 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(), makeResolveContext(),
); );
expect(result.http.maxRedirects).toBe(0); expect(result.http.maxRedirects).toBe(0);
@@ -874,7 +876,13 @@ describe("HttpChecker.resolve", () => {
test("合法 status 范围模式通过校验", () => { test("合法 status 范围模式通过校验", () => {
const result = checker.resolve( 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(), makeResolveContext(),
); );
expect(result.expect?.status).toEqual(["2xx", 301]); expect(result.expect?.status).toEqual(["2xx", 301]);
@@ -882,7 +890,12 @@ describe("HttpChecker.resolve", () => {
test("显式 ignoreSSL 和 maxRedirects 正确解析", () => { test("显式 ignoreSSL 和 maxRedirects 正确解析", () => {
const result = checker.resolve( 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(), makeResolveContext(),
); );
expect(result.http.ignoreSSL).toBe(true); expect(result.http.ignoreSSL).toBe(true);

View File

@@ -24,6 +24,10 @@ beforeAll(() => {
ensureRegistered(); ensureRegistered();
}); });
function targetId(store: ProbeStore, name: string): string {
return store.getTargets().find((target) => target.name === name)!.id;
}
const httpTarget: ResolvedHttpTarget = { const httpTarget: ResolvedHttpTarget = {
expect: { maxDurationMs: 3000, status: [200] }, expect: { maxDurationMs: 3000, status: [200] },
group: "default", group: "default",
@@ -35,6 +39,7 @@ const httpTarget: ResolvedHttpTarget = {
method: "GET", method: "GET",
url: "https://example.com/health", url: "https://example.com/health",
}, },
id: "test-http",
intervalMs: 30000, intervalMs: 30000,
name: "test-http", name: "test-http",
timeoutMs: 10000, timeoutMs: 10000,
@@ -50,6 +55,7 @@ const commandTarget: ResolvedCommandTarget = {
maxOutputBytes: 104857600, maxOutputBytes: 104857600,
}, },
group: "default", group: "default",
id: "test-cmd",
intervalMs: 60000, intervalMs: 60000,
name: "test-cmd", name: "test-cmd",
timeoutMs: 5000, timeoutMs: 5000,
@@ -79,8 +85,7 @@ describe("ProbeStore", () => {
store.syncTargets([httpTarget, commandTarget]); store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets(); const targets = store.getTargets();
expect(targets).toHaveLength(2); expect(targets).toHaveLength(2);
expect(targets[0]!.name).toBe("test-http"); expect(targets.map((target) => target.name).sort()).toEqual(["test-cmd", "test-http"]);
expect(targets[1]!.name).toBe("test-cmd");
}); });
test("http target 字段正确", () => { test("http target 字段正确", () => {
@@ -144,20 +149,18 @@ describe("ProbeStore", () => {
}); });
test("getTargetById", () => { test("getTargetById", () => {
const targets = store.getTargets(); const found = store.getTargetById(targetId(store, "test-http"));
const found = store.getTargetById(targets[0]!.id);
expect(found).toBeDefined(); expect(found).toBeDefined();
expect(found!.name).toBe("test-http"); expect(found!.name).toBe("test-http");
}); });
test("getTargetById 不存在", () => { test("getTargetById 不存在", () => {
expect(store.getTargetById(99999)).toBeNull(); expect(store.getTargetById("missing-target")).toBeNull();
}); });
test("写入 check result 并查询", () => { test("写入 check result 并查询", () => {
store.syncTargets([httpTarget, commandTarget]); store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets(); const t1Id = targetId(store, "test-http");
const t1Id = targets[0]!.id;
store.insertCheckResult({ store.insertCheckResult({
durationMs: 150.5, durationMs: 150.5,
@@ -209,8 +212,7 @@ describe("ProbeStore", () => {
}); });
test("getHistory 默认 limit=20", () => { test("getHistory 默认 limit=20", () => {
const targets = store.getTargets(); const t1Id = targetId(store, "test-http");
const t1Id = targets[0]!.id;
for (let i = 0; i < 25; i++) { for (let i = 0; i < 25; i++) {
store.insertCheckResult({ store.insertCheckResult({
@@ -228,8 +230,7 @@ describe("ProbeStore", () => {
}); });
test("getTargetWindowStats 按时间窗口计算基础计数", () => { test("getTargetWindowStats 按时间窗口计算基础计数", () => {
const targets = store.getTargets(); const t1Id = targetId(store, "test-http");
const t1Id = targets[0]!.id;
const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBeGreaterThan(0); expect(stats.totalChecks).toBeGreaterThan(0);
@@ -239,8 +240,7 @@ describe("ProbeStore", () => {
}); });
test("无记录目标的窗口 stats", () => { test("无记录目标的窗口 stats", () => {
const targets = store.getTargets(); const t2Id = targetId(store, "test-cmd");
const t2Id = targets.find((t) => t.name === "test-cmd")!.id;
const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBe(0); expect(stats.totalChecks).toBe(0);
@@ -251,16 +251,14 @@ describe("ProbeStore", () => {
test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => { test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => {
const latestChecksMap = store.getLatestChecksMap(); const latestChecksMap = store.getLatestChecksMap();
const targets = store.getTargets(); const latest = latestChecksMap.get(targetId(store, "test-http"));
const latest = latestChecksMap.get(targets[0]!.id);
expect(latest).toBeDefined(); expect(latest).toBeDefined();
expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z"); expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z");
}); });
test("getTargetCheckpoints 返回窗口内升序检查点", () => { test("getTargetCheckpoints 返回窗口内升序检查点", () => {
const targets = store.getTargets(); const t1Id = targetId(store, "test-http");
const t1Id = targets[0]!.id;
const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(checkpoints).toEqual([ expect(checkpoints).toEqual([
@@ -271,16 +269,14 @@ describe("ProbeStore", () => {
}); });
test("getTargetDurations 返回成功检查耗时升序数组", () => { test("getTargetDurations 返回成功检查耗时升序数组", () => {
const targets = store.getTargets(); const t1Id = targetId(store, "test-http");
const t1Id = targets[0]!.id;
const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(durations).toEqual([150.5, 300]); expect(durations).toEqual([150.5, 300]);
}); });
test("getRecentSamples 返回最近采样数据", () => { test("getRecentSamples 返回最近采样数据", () => {
const targets = store.getTargets(); const t1Id = targetId(store, "test-http");
const t1Id = targets[0]!.id;
const samples = store.getRecentSamples(t1Id, 10); const samples = store.getRecentSamples(t1Id, 10);
expect(Array.isArray(samples)).toBe(true); expect(Array.isArray(samples)).toBe(true);
@@ -293,15 +289,17 @@ describe("ProbeStore", () => {
test("getAllRecentSamples 返回每个 target 的最近采样数据", () => { test("getAllRecentSamples 返回每个 target 的最近采样数据", () => {
const sampleStore = new ProbeStore(join(tempDir, "all-samples.db")); 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 = { const httpB: ResolvedHttpTarget = {
...httpTarget, ...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/other" }, http: { ...httpTarget.http, url: "https://example.com/other" },
id: "sample-http-b",
name: "sample-http-b", name: "sample-http-b",
}; };
const httpEmpty: ResolvedHttpTarget = { const httpEmpty: ResolvedHttpTarget = {
...httpTarget, ...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/empty" }, http: { ...httpTarget.http, url: "https://example.com/empty" },
id: "sample-http-empty",
name: "sample-http-empty", name: "sample-http-empty",
}; };
sampleStore.syncTargets([httpA, httpB, httpEmpty]); sampleStore.syncTargets([httpA, httpB, httpEmpty]);
@@ -360,7 +358,7 @@ describe("ProbeStore", () => {
const closedStore = new ProbeStore(join(tempDir, "closed.db")); const closedStore = new ProbeStore(join(tempDir, "closed.db"));
closedStore.close(); closedStore.close();
expect(closedStore.getTargets()).toHaveLength(0); expect(closedStore.getTargets()).toHaveLength(0);
expect(closedStore.getTargetById(1)).toBeNull(); expect(closedStore.getTargetById("closed-target")).toBeNull();
}); });
test("删除 target 级联删除 check_results", () => { test("删除 target 级联删除 check_results", () => {
@@ -375,6 +373,7 @@ describe("ProbeStore", () => {
method: "GET", method: "GET",
url: "http://cascade.test", url: "http://cascade.test",
}, },
id: "cascade-test",
intervalMs: 30000, intervalMs: 30000,
name: "cascade-test", name: "cascade-test",
timeoutMs: 10000, timeoutMs: 10000,
@@ -437,6 +436,7 @@ describe("ProbeStore", () => {
method: "GET", method: "GET",
url: "http://no.records", url: "http://no.records",
}, },
id: "no-records",
intervalMs: 30000, intervalMs: 30000,
name: "no-records", name: "no-records",
timeoutMs: 10000, timeoutMs: 10000,
@@ -451,9 +451,8 @@ describe("ProbeStore", () => {
}); });
test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => { test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => {
const targets = store.getTargets(); const t1Id = targetId(store, "test-http");
const t1Id = targets[0]!.id; const t2Id = targetId(store, "test-cmd");
const t2Id = targets[1]!.id;
const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z"); const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z");
expect(stats).toBeInstanceOf(Map); expect(stats).toBeInstanceOf(Map);
@@ -484,6 +483,7 @@ describe("ProbeStore", () => {
method: "GET", method: "GET",
url: "http://no.stats", url: "http://no.stats",
}, },
id: "no-stats",
intervalMs: 30000, intervalMs: 30000,
name: "no-stats", name: "no-stats",
timeoutMs: 10000, timeoutMs: 10000,
@@ -499,7 +499,7 @@ describe("ProbeStore", () => {
test("getAllTargetWindowStats 与 getTargetWindowStats 的 availability 精度一致", () => { test("getAllTargetWindowStats 与 getTargetWindowStats 的 availability 精度一致", () => {
const statsStore = new ProbeStore(join(tempDir, "stats-precision.db")); 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]); statsStore.syncTargets([target]);
const targetId = statsStore.getTargets()[0]!.id; const targetId = statsStore.getTargets()[0]!.id;
@@ -534,10 +534,11 @@ describe("ProbeStore", () => {
test("getDashboardIncidentStates 返回按 target 和 timestamp 升序排列的状态序列", () => { test("getDashboardIncidentStates 返回按 target 和 timestamp 升序排列的状态序列", () => {
const incidentStore = new ProbeStore(join(tempDir, "dashboard-incidents.db")); 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 = { const httpB: ResolvedHttpTarget = {
...httpTarget, ...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/incident-b" }, http: { ...httpTarget.http, url: "https://example.com/incident-b" },
id: "incident-http-b",
name: "incident-http-b", name: "incident-http-b",
}; };
incidentStore.syncTargets([httpA, httpB]); incidentStore.syncTargets([httpA, httpB]);

View File

@@ -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<string, string>; 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<string, unknown> }> }).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<string, unknown> }> }).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<string, unknown> }> }).targets[0]!.http;
expect(http["maxRedirects"]).toBe(5432);
expect((http["headers"] as Record<string, string>)["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<string, string> }; 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("默认值推断为 booleantrue/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<string, unknown> }> }).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<string, unknown> }> }).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;
}

View File

@@ -10,14 +10,14 @@ import {
} from "../../src/server/middleware"; } from "../../src/server/middleware";
describe("validateTargetId", () => { describe("validateTargetId", () => {
test("有效的 target ID 返回字", () => { test("有效的 target ID 返回字符串", () => {
const result = validateTargetId("123", "production"); const result = validateTargetId("api-health_01", "production");
expect(result).not.toHaveProperty("status"); 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", () => { test("无效的 target ID 返回 400", () => {
const invalid = ["0", "-1", "abc", "1.5", ""]; const invalid = ["-1", "_abc", "has space", "1.5", ""];
for (const id of invalid) { for (const id of invalid) {
const result = validateTargetId(id, "production"); const result = validateTargetId(id, "production");

View File

@@ -10,7 +10,7 @@ describe("OverviewTab", () => {
const target: TargetStatus = { const target: TargetStatus = {
currentStreak: null, currentStreak: null,
group: "default", group: "default",
id: 1, id: "1",
interval: "30s", interval: "30s",
latestCheck: { latestCheck: {
durationMs: 100, durationMs: 100,
@@ -40,7 +40,7 @@ describe("OverviewTab", () => {
totalChecks: 20, totalChecks: 20,
upChecks: 19, upChecks: 19,
}, },
targetId: 1, targetId: "1",
trend: [], trend: [],
window: { bucket: "1h", from: "", to: "" }, window: { bucket: "1h", from: "", to: "" },
}; };

View File

@@ -20,7 +20,7 @@ describe("TargetBoard", () => {
{ {
currentStreak: null, currentStreak: null,
group: "default", group: "default",
id: 1, id: "1",
interval: "30s", interval: "30s",
latestCheck: null, latestCheck: null,
name: "target-1", name: "target-1",
@@ -32,7 +32,7 @@ describe("TargetBoard", () => {
{ {
currentStreak: null, currentStreak: null,
group: "production", group: "production",
id: 2, id: "2",
interval: "30s", interval: "30s",
latestCheck: null, latestCheck: null,
name: "target-2", name: "target-2",

View File

@@ -10,7 +10,7 @@ describe("TargetDetailDrawer", () => {
const target: TargetStatus = { const target: TargetStatus = {
currentStreak: null, currentStreak: null,
group: "default", group: "default",
id: 1, id: "1",
interval: "30s", interval: "30s",
latestCheck: { latestCheck: {
durationMs: 100, durationMs: 100,
@@ -40,7 +40,7 @@ describe("TargetDetailDrawer", () => {
totalChecks: 20, totalChecks: 20,
upChecks: 19, upChecks: 19,
}, },
targetId: 1, targetId: "1",
trend: [], trend: [],
window: { bucket: "1h", from: "", to: "" }, window: { bucket: "1h", from: "", to: "" },
}; };

View File

@@ -16,7 +16,7 @@ describe("TargetGroup", () => {
{ {
currentStreak: null, currentStreak: null,
group: "default", group: "default",
id: 1, id: "1",
interval: "30s", interval: "30s",
latestCheck: { latestCheck: {
durationMs: 100, durationMs: 100,
@@ -34,7 +34,7 @@ describe("TargetGroup", () => {
{ {
currentStreak: null, currentStreak: null,
group: "default", group: "default",
id: 2, id: "2",
interval: "30s", interval: "30s",
latestCheck: { latestCheck: {
durationMs: 100, durationMs: 100,

View File

@@ -21,7 +21,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return { return {
currentStreak: null, currentStreak: null,
group: "default", group: "default",
id: 1, id: "1",
interval: "5s", interval: "5s",
latestCheck: null, latestCheck: null,
name: "test", name: "test",

View File

@@ -13,7 +13,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return { return {
currentStreak: null, currentStreak: null,
group: "default", group: "default",
id: 1, id: "1",
interval: "5s", interval: "5s",
latestCheck: null, latestCheck: null,
name: "test", name: "test",