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