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