From 145bb8fd045277e80c13c613448f09c476766891 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 27 May 2026 00:05:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20memory=20checker?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E7=B3=BB=E7=BB=9F=E7=BA=A7=E5=86=85?= =?UTF-8?q?=E5=AD=98=E5=92=8C=E4=BA=A4=E6=8D=A2=E7=A9=BA=E9=97=B4=E6=A3=80?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 5 +- docs/user/checkers/README.md | 26 +- docs/user/checkers/memory.md | 119 +++ package.json | 1 + probe-config.schema.json | 775 ++++++++++++++++++ probes.example.yaml | 11 + src/server/checker/runner/index.ts | 2 + src/server/checker/runner/memory/calculate.ts | 60 ++ src/server/checker/runner/memory/execute.ts | 183 +++++ src/server/checker/runner/memory/expect.ts | 123 +++ src/server/checker/runner/memory/index.ts | 1 + src/server/checker/runner/memory/normalize.ts | 72 ++ src/server/checker/runner/memory/schema.ts | 55 ++ src/server/checker/runner/memory/types.ts | 75 ++ src/server/checker/runner/memory/validate.ts | 111 +++ .../checker/runner/memory/calculate.test.ts | 141 ++++ .../checker/runner/memory/execute.test.ts | 184 +++++ .../checker/runner/memory/expect.test.ts | 149 ++++ .../checker/runner/memory/normalize.test.ts | 72 ++ .../checker/runner/memory/schema.test.ts | 95 +++ .../checker/runner/memory/validate.test.ts | 87 ++ tests/server/checker/runner/registry.test.ts | 15 +- 22 files changed, 2348 insertions(+), 14 deletions(-) create mode 100644 docs/user/checkers/memory.md create mode 100644 src/server/checker/runner/memory/calculate.ts create mode 100644 src/server/checker/runner/memory/execute.ts create mode 100644 src/server/checker/runner/memory/expect.ts create mode 100644 src/server/checker/runner/memory/index.ts create mode 100644 src/server/checker/runner/memory/normalize.ts create mode 100644 src/server/checker/runner/memory/schema.ts create mode 100644 src/server/checker/runner/memory/types.ts create mode 100644 src/server/checker/runner/memory/validate.ts create mode 100644 tests/server/checker/runner/memory/calculate.test.ts create mode 100644 tests/server/checker/runner/memory/execute.test.ts create mode 100644 tests/server/checker/runner/memory/expect.test.ts create mode 100644 tests/server/checker/runner/memory/normalize.test.ts create mode 100644 tests/server/checker/runner/memory/schema.test.ts create mode 100644 tests/server/checker/runner/memory/validate.test.ts diff --git a/bun.lock b/bun.lock index c1d53d6..187d59e 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "recharts": "^3.8.1", + "systeminformation": "^5.31.6", "tdesign-icons-react": "^0.6.4", "tdesign-react": "^1.16.9", "xpath": "^0.0.34", @@ -498,7 +499,7 @@ "cosmiconfig-typescript-loader": ["cosmiconfig-typescript-loader@6.3.0", "https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", { "dependencies": { "jiti": "2.6.1" }, "peerDependencies": { "@types/node": "*", "cosmiconfig": ">=9", "typescript": ">=5" } }, "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA=="], - "croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="], + "croner": ["croner@10.0.1", "https://registry.npmmirror.com/croner/-/croner-10.0.1.tgz", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="], "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1134,6 +1135,8 @@ "synckit": ["synckit@0.11.12", "https://registry.npmmirror.com/synckit/-/synckit-0.11.12.tgz", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], + "systeminformation": ["systeminformation@5.31.6", "https://registry.npmmirror.com/systeminformation/-/systeminformation-5.31.6.tgz", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="], + "tar-stream": ["tar-stream@3.2.0", "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.2.0.tgz", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], "tdesign-icons-react": ["tdesign-icons-react@0.6.4", "https://registry.npmmirror.com/tdesign-icons-react/-/tdesign-icons-react-0.6.4.tgz", { "dependencies": { "@babel/runtime": "^7.16.5", "classnames": "^2.2.6" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-USAoi9vBWcwcJT45VqR3dRqX1MeAsn/RhHVx4bLwplhrlvE80ZQ1N9V+6F3HqE1Qe9mMDbtRM8Ul80+lALScww=="], diff --git a/docs/user/checkers/README.md b/docs/user/checkers/README.md index 5235883..7189af0 100644 --- a/docs/user/checkers/README.md +++ b/docs/user/checkers/README.md @@ -6,18 +6,19 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一 ## 支持的类型 -| 类型 | 用途 | 文档 | -| ------ | -------------------------------------- | --------------- | -| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) | -| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) | -| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) | -| `tcp` | TCP 端口可达性和 banner 探测 | [TCP](tcp.md) | -| `udp` | UDP payload 请求-响应检查 | [UDP](udp.md) | -| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) | -| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) | -| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) | -| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) | -| `cpu` | 本机 CPU 使用率健康检查 | [CPU](cpu.md) | +| 类型 | 用途 | 文档 | +| -------- | -------------------------------------- | ------------------- | +| `http` | HTTP/HTTPS 应用层健康检查 | [HTTP](http.md) | +| `cmd` | 执行本地命令或脚本 | [Cmd](cmd.md) | +| `db` | PostgreSQL/MySQL/SQLite 连接和查询检查 | [DB](db.md) | +| `tcp` | TCP 端口可达性和 banner 探测 | [TCP](tcp.md) | +| `udp` | UDP payload 请求-响应检查 | [UDP](udp.md) | +| `icmp` | 基于系统 `ping` 的存活、延迟、丢包检查 | [ICMP](icmp.md) | +| `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) | +| `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) | +| `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) | +| `cpu` | 本机 CPU 使用率健康检查 | [CPU](cpu.md) | +| `memory` | 本机系统内存使用状况检查 | [Memory](memory.md) | ## 选择建议 @@ -33,6 +34,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一 | LLM API 是否可用、输出是否符合预期 | `llm` | | WebSocket 可达性或消息交互验证 | `ws` | | 本机 CPU 使用率健康检查 | `cpu` | +| 本机系统内存使用状况检查 | `memory` | ## 通用字段 diff --git a/docs/user/checkers/memory.md b/docs/user/checkers/memory.md new file mode 100644 index 0000000..a420ce7 --- /dev/null +++ b/docs/user/checkers/memory.md @@ -0,0 +1,119 @@ +# Memory Checker + +`type: memory` 用于检查本机系统级内存使用状况,包括物理内存和交换空间的使用率及字节数。 + +## 配置项 + +Memory checker 配置为空对象,无需额外参数: + +```yaml +memory: {} +``` + +## expect 校验项 + +### 百分比字段 + +| 字段 | 说明 | 必填 | 默认值 | +| ------------------ | -------------------------------------------------------------------------- | ---- | ------ | +| `usagePercent` | 真实内存使用率 = `activeBytes / totalBytes × 100`,不含 buffers/cache 假象 | 否 | 无 | +| `usedPercent` | 原始已用百分比 = `usedBytes / totalBytes × 100`,包含 buffers/cache | 否 | 无 | +| `freePercent` | 空闲百分比 = `freeBytes / totalBytes × 100` | 否 | 无 | +| `activePercent` | 活跃内存百分比 = `activeBytes / totalBytes × 100` | 否 | 无 | +| `availablePercent` | 可用内存百分比 = `availableBytes / totalBytes × 100` | 否 | 无 | +| `swapUsagePercent` | 交换空间使用率,当系统无交换分区时为 `null` | 否 | 无 | + +所有百分比字段范围为 `0-100`,使用 `ValueMatcher`。 + +### 字节字段 + +| 字段 | 说明 | 必填 | 默认值 | +| ---------------- | ----------------------------------------- | ---- | ------ | +| `activeBytes` | 活跃内存字节数 | 否 | 无 | +| `usedBytes` | 已用内存字节数(含 buffers/cache) | 否 | 无 | +| `freeBytes` | 空闲内存字节数 | 否 | 无 | +| `availableBytes` | 可用内存字节数 | 否 | 无 | +| `totalBytes` | 物理内存总字节数 | 否 | 无 | +| `swapUsedBytes` | 交换空间已用字节数,无交换分区时为 `null` | 否 | 无 | +| `swapFreeBytes` | 交换空间空闲字节数,无交换分区时为 `null` | 否 | 无 | +| `swapTotalBytes` | 交换空间总字节数,无交换分区时为 `0` | 否 | 无 | +| `buffcacheBytes` | 缓冲缓存字节数,部分平台可能为 `null` | 否 | 无 | + +字节字段支持数字(字节数)或大小字符串(如 `"512MB"`、`"1GB"`),使用 `ValueMatcher`。 + +### 通用字段 + +| 字段 | 说明 | 必填 | 默认值 | +| ------------ | ------------------------------------- | ---- | ------ | +| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 | + +## 示例 + +检查内存使用率不超过 85%: + +```yaml +- id: "local-memory" + name: "本机内存" + type: memory + interval: "30s" + timeout: "5s" + memory: {} + expect: + usagePercent: + lte: 85 +``` + +检查可用内存不低于 4GB: + +```yaml +- id: "local-memory-available" + name: "可用内存检查" + type: memory + memory: {} + expect: + availableBytes: + gte: "4GB" +``` + +同时检查内存和交换空间: + +```yaml +- id: "local-memory-swap" + name: "内存和交换空间" + type: memory + memory: {} + expect: + usagePercent: + lte: 80 + swapUsagePercent: + lte: 50 +``` + +## 语义说明 + +Memory checker 通过 `systeminformation` 库读取系统内存数据,在 Linux、macOS 和 Windows 上均可运行。 + +- **`usagePercent`** 使用 `activeBytes / totalBytes` 计算,反映真实的内存压力,不受 Linux buffers/cache 缓存影响。推荐使用此字段进行内存健康检查。 +- **`usedPercent`** 使用 `usedBytes / totalBytes` 计算,包含 buffers/cache。在 Linux 上此值通常高于 `usagePercent`。 +- **Swap 字段**:当系统未配置交换分区时,`swapTotalBytes` 为 `0`,`swapUsagePercent` 为 `null`(非 `0`)。 +- **`buffcacheBytes`**:反映 Linux 的 buffers + cache 用量,在其他平台上可能为 `null`。 + +Memory checker 是即时读取(非采样),无需 `sampleDuration`,执行速度远快于 CPU checker。 + +## 跨平台注意事项 + +- Windows 环境依赖 PowerShell 5+ 获取部分内存指标 +- `buffcacheBytes` 在非 Linux 平台上可能返回 `null` +- 容器环境中内存数据可能不反映 cgroup 内存限制 + +## 不支持的功能 + +- 进程级内存使用(如 RSS、VSZ) +- cgroup/container 内存限制精度 +- 内存趋势采样和历史记录 +- 内存条物理布局信息 +- 详细内存分类(slab、reclaimable、dirty 等)作为 expect 字段 + +## 更新触发条件 + +修改 Memory checker 配置、expect 字段、行为或语义时,必须更新本文档。 diff --git a/package.json b/package.json index 5bd1eed..4ca5e95 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "recharts": "^3.8.1", + "systeminformation": "^5.31.6", "tdesign-icons-react": "^0.6.4", "tdesign-react": "^1.16.9", "xpath": "^0.0.34" diff --git a/probe-config.schema.json b/probe-config.schema.json index 08dae17..48420d1 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -6850,6 +6850,781 @@ } } } + }, + { + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "type", + "memory" + ], + "properties": { + "description": { + "anyOf": [ + { + "type": "null" + }, + { + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + } + ] + }, + "expect": { + "additionalProperties": false, + "type": "object", + "properties": { + "activeBytes": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "activePercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "availableBytes": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "availablePercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "buffcacheBytes": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "durationMs": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "freeBytes": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "freePercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "swapFreeBytes": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "swapTotalBytes": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "swapUsagePercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "swapUsedBytes": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "totalBytes": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "usagePercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "usedBytes": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "usedPercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + } + } + }, + "group": { + "type": "string" + }, + "id": { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + "interval": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "null" + }, + { + "anyOf": [ + { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + } + ] + }, + "timeout": { + "type": "string" + }, + "type": { + "const": "memory", + "type": "string" + }, + "memory": { + "additionalProperties": false, + "type": "object", + "properties": {} + } + } } ] } diff --git a/probes.example.yaml b/probes.example.yaml index 85a62e4..5f0840e 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -367,3 +367,14 @@ targets: lte: 85 maxCoreUsagePercent: lte: 95 + + - id: "local-memory" + name: "本机内存" + type: memory + group: "基础设施" + interval: "30s" + timeout: "5s" + memory: {} + expect: + usagePercent: + lte: 85 diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts index 8ad510c..3a54017 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -5,6 +5,7 @@ import { DnsChecker } from "./dns"; import { HttpChecker } from "./http"; import { IcmpChecker } from "./icmp"; import { LlmChecker } from "./llm"; +import { MemoryChecker } from "./memory"; import { CheckerRegistry } from "./registry"; import { TcpChecker } from "./tcp"; import { UdpChecker } from "./udp"; @@ -21,6 +22,7 @@ const checkers = [ new DnsChecker(), new WsChecker(), new CpuChecker(), + new MemoryChecker(), ]; export function createDefaultCheckerRegistry(): CheckerRegistry { diff --git a/src/server/checker/runner/memory/calculate.ts b/src/server/checker/runner/memory/calculate.ts new file mode 100644 index 0000000..5d1a77e --- /dev/null +++ b/src/server/checker/runner/memory/calculate.ts @@ -0,0 +1,60 @@ +import type { Systeminformation } from "systeminformation"; + +import type { MemoryStats } from "./types"; + +export function calculateMemoryStats(data: Systeminformation.MemData): MemoryStats { + const totalBytes = data.total; + const usedBytes = data.used; + const activeBytes = data.active; + const availableBytes = data.available; + const freeBytes = data.free; + + const usagePercent = totalBytes > 0 ? round1((activeBytes / totalBytes) * 100) : 0; + const usedPercent = totalBytes > 0 ? round1((usedBytes / totalBytes) * 100) : 0; + const freePercent = totalBytes > 0 ? round1((freeBytes / totalBytes) * 100) : 0; + const activePercent = totalBytes > 0 ? round1((activeBytes / totalBytes) * 100) : 0; + const availablePercent = totalBytes > 0 ? round1((availableBytes / totalBytes) * 100) : 0; + + const swapTotalBytes = data.swaptotal; + const swapUsedBytes = data.swapused; + const swapFreeBytes = data.swapfree; + + const swapUsagePercent = resolveSwapUsagePercent(swapTotalBytes, swapUsedBytes); + const buffcacheBytes = resolveNullableNumber(data.buffcache); + + return { + activeBytes, + activePercent, + availableBytes, + availablePercent, + buffcacheBytes, + freeBytes, + freePercent, + swapFreeBytes: resolveNullableNumber(swapFreeBytes), + swapTotalBytes: resolveNullableNumber(swapTotalBytes), + swapUsagePercent, + swapUsedBytes: resolveNullableNumber(swapUsedBytes), + totalBytes, + usagePercent, + usedBytes, + usedPercent, + }; +} + +export async function readMemoryData(): Promise { + const si = await import("systeminformation"); + return si.mem(); +} + +function resolveNullableNumber(value: number): null | number { + return value > 0 ? value : value === 0 ? 0 : null; +} + +function resolveSwapUsagePercent(swapTotal: number, swapUsed: number): null | number { + if (swapTotal === 0) return null; + return round1((swapUsed / swapTotal) * 100); +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} diff --git a/src/server/checker/runner/memory/execute.ts b/src/server/checker/runner/memory/execute.ts new file mode 100644 index 0000000..b7386a1 --- /dev/null +++ b/src/server/checker/runner/memory/execute.ts @@ -0,0 +1,183 @@ +import type { Systeminformation } from "systeminformation"; + +import type { CheckResult, RawTargetConfig } from "../../types"; +import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; +import type { MemoryStats, ResolvedMemoryExpectConfig, ResolvedMemoryTarget } from "./types"; + +import { errorFailure } from "../../expect/failure"; +import { checkValueExpectation } from "../../expect/value"; +import { calculateMemoryStats, readMemoryData } from "./calculate"; +import { + checkActiveBytes, + checkActivePercent, + checkAvailableBytes, + checkAvailablePercent, + checkBuffcacheBytes, + checkFreeBytes, + checkFreePercent, + checkSwapFreeBytes, + checkSwapTotalBytes, + checkSwapUsagePercent, + checkSwapUsedBytes, + checkTotalBytes, + checkUsagePercent, + checkUsedBytes, + checkUsedPercent, +} from "./expect"; +import { normalizeTargetExpect } from "./normalize"; +import { memoryCheckerSchemas } from "./schema"; +import { validateMemoryConfig } from "./validate"; + +export class MemoryChecker implements CheckerDefinition { + readonly configKey = "memory"; + + readonly schemas = memoryCheckerSchemas; + + readonly type = "memory"; + + constructor(private readonly reader: () => Promise = readMemoryData) {} + + buildDetail(observation: Record): null | string { + const usage = observation["usagePercent"]; + const usageStr = typeof usage === "number" ? formatNumber(usage) : "n/a"; + const total = observation["totalBytes"]; + const totalStr = typeof total === "number" ? formatBytes(total) : "n/a"; + return `usage ${usageStr}%, total ${totalStr}`; + } + + async execute(t: ResolvedMemoryTarget, _ctx: CheckerContext): Promise { + const timestamp = new Date().toISOString(); + const start = performance.now(); + + let data: Systeminformation.MemData; + try { + data = await this.reader(); + } catch (error) { + const durationMs = Math.round(performance.now() - start); + return { + detail: null, + durationMs, + failure: errorFailure( + "memory", + "snapshot", + `内存数据读取失败: ${error instanceof Error ? error.message : String(error)}`, + ), + matched: false, + observation: null, + targetId: t.id, + timestamp, + }; + } + + const durationMs = Math.round(performance.now() - start); + const stats = calculateMemoryStats(data); + const result = checkStats(stats, t.expect, durationMs); + + const observation: Record = { + activeBytes: stats.activeBytes, + activePercent: stats.activePercent, + availableBytes: stats.availableBytes, + availablePercent: stats.availablePercent, + buffcacheBytes: stats.buffcacheBytes, + error: null, + freeBytes: stats.freeBytes, + freePercent: stats.freePercent, + swapFreeBytes: stats.swapFreeBytes, + swapTotalBytes: stats.swapTotalBytes, + swapUsagePercent: stats.swapUsagePercent, + swapUsedBytes: stats.swapUsedBytes, + totalBytes: stats.totalBytes, + usagePercent: stats.usagePercent, + usedBytes: stats.usedBytes, + usedPercent: stats.usedPercent, + }; + + return { + detail: null, + durationMs, + failure: result.failure, + matched: result.matched, + observation, + targetId: t.id, + timestamp, + }; + } + + normalize(target: RawTargetConfig): RawTargetConfig { + return normalizeTargetExpect(target); + } + + resolve(target: RawTargetConfig, context: ResolveContext): ResolvedMemoryTarget { + return { + description: null, + expect: target.expect as ResolvedMemoryExpectConfig | undefined, + group: target.group ?? "default", + id: target.id, + intervalMs: context.defaultIntervalMs, + memory: {}, + name: target.name ?? null, + timeoutMs: context.defaultTimeoutMs, + type: "memory", + } satisfies ResolvedMemoryTarget; + } + + serialize(t: ResolvedMemoryTarget): { config: string; target: string } { + return { + config: JSON.stringify(t.memory), + target: `memory`, + }; + } + + validate(input: CheckerValidationInput) { + return validateMemoryConfig(input); + } +} + +function checkStats(stats: MemoryStats, expect: ResolvedMemoryExpectConfig | undefined, durationMs: number) { + let result = checkUsagePercent(stats.usagePercent, expect?.usagePercent); + if (!result.matched) return result; + result = checkUsedPercent(stats.usedPercent, expect?.usedPercent); + if (!result.matched) return result; + result = checkFreePercent(stats.freePercent, expect?.freePercent); + if (!result.matched) return result; + result = checkActivePercent(stats.activePercent, expect?.activePercent); + if (!result.matched) return result; + result = checkAvailablePercent(stats.availablePercent, expect?.availablePercent); + if (!result.matched) return result; + result = checkActiveBytes(stats.activeBytes, expect?.activeBytes); + if (!result.matched) return result; + result = checkUsedBytes(stats.usedBytes, expect?.usedBytes); + if (!result.matched) return result; + result = checkFreeBytes(stats.freeBytes, expect?.freeBytes); + if (!result.matched) return result; + result = checkAvailableBytes(stats.availableBytes, expect?.availableBytes); + if (!result.matched) return result; + result = checkTotalBytes(stats.totalBytes, expect?.totalBytes); + if (!result.matched) return result; + result = checkSwapUsagePercent(stats.swapUsagePercent, expect?.swapUsagePercent); + if (!result.matched) return result; + result = checkSwapUsedBytes(stats.swapUsedBytes, expect?.swapUsedBytes); + if (!result.matched) return result; + result = checkSwapFreeBytes(stats.swapFreeBytes, expect?.swapFreeBytes); + if (!result.matched) return result; + result = checkSwapTotalBytes(stats.swapTotalBytes, expect?.swapTotalBytes); + if (!result.matched) return result; + result = checkBuffcacheBytes(stats.buffcacheBytes, expect?.buffcacheBytes); + if (!result.matched) return result; + return checkValueExpectation(durationMs, expect?.durationMs, { + message: "durationMs mismatch", + path: "durationMs", + phase: "duration", + }); +} + +function formatBytes(bytes: number): string { + if (bytes >= 1073741824) return `${formatNumber(bytes / 1073741824)}GB`; + if (bytes >= 1048576) return `${formatNumber(bytes / 1048576)}MB`; + if (bytes >= 1024) return `${formatNumber(bytes / 1024)}KB`; + return `${bytes}B`; +} + +function formatNumber(value: number): string { + return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(1))); +} diff --git a/src/server/checker/runner/memory/expect.ts b/src/server/checker/runner/memory/expect.ts new file mode 100644 index 0000000..a2230a9 --- /dev/null +++ b/src/server/checker/runner/memory/expect.ts @@ -0,0 +1,123 @@ +import type { ExpectationResult, ValueExpectation } from "../../expect/types"; + +import { checkValueExpectation } from "../../expect/value"; + +export function checkActiveBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存活跃字节数不满足条件", + path: "activeBytes", + phase: "activeBytes", + }); +} + +export function checkActivePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存活跃百分比不满足条件", + path: "activePercent", + phase: "active", + }); +} + +export function checkAvailableBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存可用字节数不满足条件", + path: "availableBytes", + phase: "availableBytes", + }); +} + +export function checkAvailablePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存可用百分比不满足条件", + path: "availablePercent", + phase: "available", + }); +} + +export function checkBuffcacheBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "缓冲缓存字节数不满足条件", + path: "buffcacheBytes", + phase: "buffcacheBytes", + }); +} + +export function checkFreeBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存空闲字节数不满足条件", + path: "freeBytes", + phase: "freeBytes", + }); +} + +export function checkFreePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存空闲百分比不满足条件", + path: "freePercent", + phase: "free", + }); +} + +export function checkSwapFreeBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "交换空间空闲字节数不满足条件", + path: "swapFreeBytes", + phase: "swapFreeBytes", + }); +} + +export function checkSwapTotalBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "交换空间总字节数不满足条件", + path: "swapTotalBytes", + phase: "swapTotalBytes", + }); +} + +export function checkSwapUsagePercent(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "交换空间使用率不满足条件", + path: "swapUsagePercent", + phase: "swapUsage", + }); +} + +export function checkSwapUsedBytes(actual: null | number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "交换空间已用字节数不满足条件", + path: "swapUsedBytes", + phase: "swapUsedBytes", + }); +} + +export function checkTotalBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存总字节数不满足条件", + path: "totalBytes", + phase: "totalBytes", + }); +} + +export function checkUsagePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存使用率不满足条件", + path: "usagePercent", + phase: "usage", + }); +} + +export function checkUsedBytes(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存已用字节数不满足条件", + path: "usedBytes", + phase: "usedBytes", + }); +} + +export function checkUsedPercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult { + return checkValueExpectation(actual, matcher, { + message: "内存已用百分比不满足条件", + path: "usedPercent", + phase: "used", + }); +} diff --git a/src/server/checker/runner/memory/index.ts b/src/server/checker/runner/memory/index.ts new file mode 100644 index 0000000..57becfa --- /dev/null +++ b/src/server/checker/runner/memory/index.ts @@ -0,0 +1 @@ +export { MemoryChecker } from "./execute"; diff --git a/src/server/checker/runner/memory/normalize.ts b/src/server/checker/runner/memory/normalize.ts new file mode 100644 index 0000000..a2e3f11 --- /dev/null +++ b/src/server/checker/runner/memory/normalize.ts @@ -0,0 +1,72 @@ +import { isNumber, isPlainObject, isString } from "es-toolkit"; + +import type { RawTargetConfig } from "../../types"; + +import { compactExpect, normalizeValue } from "../../expect/normalize"; +import { parseSize } from "../../utils"; + +const BYTE_FIELDS = new Set([ + "activeBytes", + "availableBytes", + "buffcacheBytes", + "freeBytes", + "swapFreeBytes", + "swapTotalBytes", + "swapUsedBytes", + "totalBytes", + "usedBytes", +]); + +const PERCENT_FIELDS = new Set([ + "activePercent", + "availablePercent", + "freePercent", + "swapUsagePercent", + "usagePercent", + "usedPercent", +]); + +export function normalizeByteValue(value: unknown): unknown { + if (value === undefined) return undefined; + if (isString(value)) { + const parsed = parseSize(value); + return normalizeValue(parsed); + } + if (isNumber(value)) { + return normalizeValue(value); + } + if (isPlainObject(value)) { + const obj = value as Record; + const converted: Record = {}; + for (const [k, v] of Object.entries(obj)) { + converted[k] = isString(v) ? parseSize(v) : v; + } + return normalizeValue(converted); + } + return normalizeValue(value); +} + +export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig { + if (target.expect === undefined || !isPlainObject(target.expect)) return target; + const raw = target.expect as Record; + return { + ...target, + expect: compactExpect(raw, normalizeAllFields(raw)), + }; +} + +function normalizeAllFields(raw: Record): Record { + const result: Record = {}; + + for (const key of BYTE_FIELDS) { + result[key] = normalizeByteValue(raw[key]); + } + + for (const key of PERCENT_FIELDS) { + result[key] = normalizeValue(raw[key]); + } + + result["durationMs"] = normalizeValue(raw["durationMs"]); + + return result; +} diff --git a/src/server/checker/runner/memory/schema.ts b/src/server/checker/runner/memory/schema.ts new file mode 100644 index 0000000..e6af26a --- /dev/null +++ b/src/server/checker/runner/memory/schema.ts @@ -0,0 +1,55 @@ +import { Type } from "@sinclair/typebox"; + +import type { CheckerSchemas } from "../types"; + +import { + createAuthoringFieldSchema, + createAuthoringValueExpectationSchema, + createNormalizedValueExpectationSchema, + sizeSchema, +} from "../../schema/fragments"; + +export const memoryCheckerSchemas: CheckerSchemas = { + authoring: { + config: createMemoryConfigSchema("authoring"), + expect: createMemoryExpectSchema("authoring"), + }, + normalized: { + config: createMemoryConfigSchema("normalized"), + expect: createMemoryExpectSchema("normalized"), + }, +}; + +function createMemoryConfigSchema(_kind: "authoring" | "normalized") { + return Type.Object({}, { additionalProperties: false }); +} + +function createMemoryExpectSchema(kind: "authoring" | "normalized") { + const valueSchema = + kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema(); + + const byteValueSchema = + kind === "authoring" ? createAuthoringFieldSchema(sizeSchema) : createNormalizedValueExpectationSchema(); + + return Type.Object( + { + activeBytes: Type.Optional(byteValueSchema), + activePercent: Type.Optional(valueSchema), + availableBytes: Type.Optional(byteValueSchema), + availablePercent: Type.Optional(valueSchema), + buffcacheBytes: Type.Optional(byteValueSchema), + durationMs: Type.Optional(valueSchema), + freeBytes: Type.Optional(byteValueSchema), + freePercent: Type.Optional(valueSchema), + swapFreeBytes: Type.Optional(byteValueSchema), + swapTotalBytes: Type.Optional(byteValueSchema), + swapUsagePercent: Type.Optional(valueSchema), + swapUsedBytes: Type.Optional(byteValueSchema), + totalBytes: Type.Optional(byteValueSchema), + usagePercent: Type.Optional(valueSchema), + usedBytes: Type.Optional(byteValueSchema), + usedPercent: Type.Optional(valueSchema), + }, + { additionalProperties: false }, + ); +} diff --git a/src/server/checker/runner/memory/types.ts b/src/server/checker/runner/memory/types.ts new file mode 100644 index 0000000..9868a47 --- /dev/null +++ b/src/server/checker/runner/memory/types.ts @@ -0,0 +1,75 @@ +import type { Systeminformation } from "systeminformation"; + +import type { RawValueExpectation, ValueExpectation } from "../../expect/types"; +import type { ResolvedTargetBase } from "../../types"; + +export type MemoryDataReader = () => Promise; + +export interface MemoryStats { + activeBytes: number; + activePercent: number; + availableBytes: number; + availablePercent: number; + buffcacheBytes: null | number; + freeBytes: number; + freePercent: number; + swapFreeBytes: null | number; + swapTotalBytes: null | number; + swapUsagePercent: null | number; + swapUsedBytes: null | number; + totalBytes: number; + usagePercent: number; + usedBytes: number; + usedPercent: number; +} + +export interface RawMemoryExpectConfig { + activeBytes?: RawValueExpectation; + activePercent?: RawValueExpectation; + availableBytes?: RawValueExpectation; + availablePercent?: RawValueExpectation; + buffcacheBytes?: RawValueExpectation; + durationMs?: RawValueExpectation; + freeBytes?: RawValueExpectation; + freePercent?: RawValueExpectation; + swapFreeBytes?: RawValueExpectation; + swapTotalBytes?: RawValueExpectation; + swapUsagePercent?: RawValueExpectation; + swapUsedBytes?: RawValueExpectation; + totalBytes?: RawValueExpectation; + usagePercent?: RawValueExpectation; + usedBytes?: RawValueExpectation; + usedPercent?: RawValueExpectation; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ResolvedMemoryConfig {} + +export interface ResolvedMemoryExpectConfig { + activeBytes?: ValueExpectation; + activePercent?: ValueExpectation; + availableBytes?: ValueExpectation; + availablePercent?: ValueExpectation; + buffcacheBytes?: ValueExpectation; + durationMs?: ValueExpectation; + freeBytes?: ValueExpectation; + freePercent?: ValueExpectation; + swapFreeBytes?: ValueExpectation; + swapTotalBytes?: ValueExpectation; + swapUsagePercent?: ValueExpectation; + swapUsedBytes?: ValueExpectation; + totalBytes?: ValueExpectation; + usagePercent?: ValueExpectation; + usedBytes?: ValueExpectation; + usedPercent?: ValueExpectation; +} + +export interface ResolvedMemoryTarget extends ResolvedTargetBase { + expect?: ResolvedMemoryExpectConfig; + group: string; + intervalMs: number; + memory: ResolvedMemoryConfig; + name: null | string; + timeoutMs: number; + type: "memory"; +} diff --git a/src/server/checker/runner/memory/validate.ts b/src/server/checker/runner/memory/validate.ts new file mode 100644 index 0000000..5a6cd91 --- /dev/null +++ b/src/server/checker/runner/memory/validate.ts @@ -0,0 +1,111 @@ +import { isString } from "es-toolkit"; + +import type { ConfigValidationIssue } from "../../schema/issues"; +import type { CheckerValidationInput } from "../types"; + +import { isPlainRecord, validateRawValueExpectation } from "../../expect/validate"; +import { issue, joinPath } from "../../schema/issues"; +import { parseSize } from "../../utils"; + +const MEMORY_CONFIG_KEYS = new Set([]); + +const MEMORY_EXPECT_FIELDS = [ + "activeBytes", + "activePercent", + "availableBytes", + "availablePercent", + "buffcacheBytes", + "durationMs", + "freeBytes", + "freePercent", + "swapFreeBytes", + "swapTotalBytes", + "swapUsagePercent", + "swapUsedBytes", + "totalBytes", + "usagePercent", + "usedBytes", + "usedPercent", +] as const; + +const BYTE_EXPECT_FIELDS = new Set([ + "activeBytes", + "availableBytes", + "buffcacheBytes", + "freeBytes", + "swapFreeBytes", + "swapTotalBytes", + "swapUsedBytes", + "totalBytes", + "usedBytes", +]); + +const MEMORY_EXPECT_KEYS = new Set(MEMORY_EXPECT_FIELDS); + +export function validateMemoryConfig(input: CheckerValidationInput): ConfigValidationIssue[] { + const issues: ConfigValidationIssue[] = []; + + for (let i = 0; i < input.targets.length; i++) { + const target = input.targets[i] as unknown; + if (!isPlainRecord(target)) continue; + if (target["type"] !== "memory") continue; + issues.push(...validateMemoryTarget(target, `targets[${i}]`)); + } + + return issues; +} + +function getTargetName(target: Record): string | undefined { + if (isString(target["name"])) return target["name"]; + return isString(target["id"]) ? target["id"] : undefined; +} + +function validateMemoryExpect(target: Record, path: string): ConfigValidationIssue[] { + const rawExpect = target["expect"]; + if (rawExpect === undefined || rawExpect === null || !isPlainRecord(rawExpect)) return []; + const expect = rawExpect; + const issues: ConfigValidationIssue[] = []; + const targetName = getTargetName(target); + const expectPath = joinPath(path, "expect"); + + for (const key of MEMORY_EXPECT_FIELDS) { + if (expect[key] !== undefined) { + issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName)); + if (BYTE_EXPECT_FIELDS.has(key) && isString(expect[key])) { + try { + parseSize(expect[key]); + } catch { + issues.push(issue("invalid-value", joinPath(expectPath, key), "不是有效的字节大小格式", targetName)); + } + } + } + } + + for (const key of Object.keys(expect)) { + if (!MEMORY_EXPECT_KEYS.has(key)) { + issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName)); + } + } + + return issues; +} + +function validateMemoryTarget(target: Record, path: string): ConfigValidationIssue[] { + const issues: ConfigValidationIssue[] = []; + const targetName = getTargetName(target); + + const rawMemory = target["memory"]; + if (!isPlainRecord(rawMemory)) { + issues.push(issue("required", joinPath(path, "memory"), "缺少 memory 配置分组", targetName)); + } else { + for (const key of Object.keys(rawMemory)) { + if (!MEMORY_CONFIG_KEYS.has(key)) { + issues.push(issue("unknown-field", joinPath(joinPath(path, "memory"), key), "是未知字段", targetName)); + } + } + } + + issues.push(...validateMemoryExpect(target, path)); + + return issues; +} diff --git a/tests/server/checker/runner/memory/calculate.test.ts b/tests/server/checker/runner/memory/calculate.test.ts new file mode 100644 index 0000000..2d2d027 --- /dev/null +++ b/tests/server/checker/runner/memory/calculate.test.ts @@ -0,0 +1,141 @@ +import type { Systeminformation } from "systeminformation"; + +import { describe, expect, test } from "bun:test"; + +import { calculateMemoryStats } from "../../../../../src/server/checker/runner/memory/calculate"; + +function makeMemData(overrides: Partial = {}): Systeminformation.MemData { + return { + active: 4294967296, + available: 8589934592, + buffcache: 1073741824, + buffers: 536870912, + cached: 536870912, + dirty: null, + free: 4294967296, + reclaimable: 0, + slab: 0, + swapfree: 0, + swaptotal: 0, + swapused: 0, + total: 17179869184, + used: 8589934592, + writeback: null, + ...overrides, + }; +} + +describe("calculateMemoryStats", () => { + test("usagePercent = activeBytes / totalBytes * 100", () => { + const stats = calculateMemoryStats(makeMemData({ active: 4294967296, total: 8589934592 })); + expect(stats.usagePercent).toBe(50); + }); + + test("usedPercent = usedBytes / totalBytes * 100", () => { + const stats = calculateMemoryStats(makeMemData({ total: 8589934592, used: 6442450944 })); + expect(stats.usedPercent).toBe(75); + }); + + test("freePercent = freeBytes / totalBytes * 100", () => { + const stats = calculateMemoryStats(makeMemData({ free: 2147483648, total: 8589934592 })); + expect(stats.freePercent).toBe(25); + }); + + test("activePercent = activeBytes / totalBytes * 100", () => { + const stats = calculateMemoryStats(makeMemData({ active: 3221225472, total: 8589934592 })); + expect(stats.activePercent).toBe(37.5); + }); + + test("availablePercent = availableBytes / totalBytes * 100", () => { + const stats = calculateMemoryStats(makeMemData({ available: 6442450944, total: 8589934592 })); + expect(stats.availablePercent).toBe(75); + }); + + test("保留 1 位小数", () => { + const stats = calculateMemoryStats(makeMemData({ active: 3000000000, total: 8000000000 })); + expect(stats.usagePercent).toBe(37.5); + }); + + test("round1 处理需要四舍五入的情况", () => { + const stats = calculateMemoryStats(makeMemData({ active: 3333333333, total: 10000000000 })); + expect(stats.usagePercent).toBe(33.3); + }); + + test("total 为 0 时百分比为 0", () => { + const stats = calculateMemoryStats(makeMemData({ active: 0, available: 0, free: 0, total: 0, used: 0 })); + expect(stats.usagePercent).toBe(0); + expect(stats.usedPercent).toBe(0); + expect(stats.freePercent).toBe(0); + }); + + test("buffcacheBytes 为 null 映射", () => { + const stats = calculateMemoryStats(makeMemData({ buffcache: 0 })); + expect(stats.buffcacheBytes).toBe(0); + }); + + test("buffcacheBytes 为正数时保留", () => { + const stats = calculateMemoryStats(makeMemData({ buffcache: 1073741824 })); + expect(stats.buffcacheBytes).toBe(1073741824); + }); + + test("所有字节字段正确映射", () => { + const data = makeMemData({ + active: 1000, + available: 2000, + free: 3000, + total: 4000, + used: 3500, + }); + const stats = calculateMemoryStats(data); + expect(stats.activeBytes).toBe(1000); + expect(stats.availableBytes).toBe(2000); + expect(stats.freeBytes).toBe(3000); + expect(stats.totalBytes).toBe(4000); + expect(stats.usedBytes).toBe(3500); + }); +}); + +describe("calculateMemoryStats swap", () => { + test("swap 不可用:swaptotal=0 时 swapUsagePercent=null", () => { + const stats = calculateMemoryStats(makeMemData({ swapfree: 0, swaptotal: 0, swapused: 0 })); + expect(stats.swapUsagePercent).toBe(null); + expect(stats.swapTotalBytes).toBe(0); + expect(stats.swapUsedBytes).toBe(0); + expect(stats.swapFreeBytes).toBe(0); + }); + + test("swap 总量为 0,swapUsagePercent 为 null(不是 0)", () => { + const stats = calculateMemoryStats(makeMemData({ swaptotal: 0 })); + expect(stats.swapUsagePercent).toBe(null); + }); + + test("swap 已使用", () => { + const stats = calculateMemoryStats( + makeMemData({ swapfree: 1073741824, swaptotal: 4294967296, swapused: 3221225472 }), + ); + expect(stats.swapUsagePercent).toBe(75); + expect(stats.swapTotalBytes).toBe(4294967296); + expect(stats.swapUsedBytes).toBe(3221225472); + expect(stats.swapFreeBytes).toBe(1073741824); + }); + + test("swap 未使用:swapUsagePercent=0(不是 null)", () => { + const stats = calculateMemoryStats(makeMemData({ swapfree: 4294967296, swaptotal: 4294967296, swapused: 0 })); + expect(stats.swapUsagePercent).toBe(0); + expect(stats.swapUsedBytes).toBe(0); + expect(stats.swapFreeBytes).toBe(4294967296); + }); + + test("swap 部分使用保留 1 位小数", () => { + const stats = calculateMemoryStats( + makeMemData({ swapfree: 3000000000, swaptotal: 10000000000, swapused: 7000000000 }), + ); + expect(stats.swapUsagePercent).toBe(70); + }); + + test("swap 合法 0 不被转换为 null", () => { + const stats = calculateMemoryStats(makeMemData({ swapfree: 4294967296, swaptotal: 4294967296, swapused: 0 })); + expect(stats.swapUsedBytes).toBe(0); + expect(stats.swapUsagePercent).toBe(0); + }); +}); diff --git a/tests/server/checker/runner/memory/execute.test.ts b/tests/server/checker/runner/memory/execute.test.ts new file mode 100644 index 0000000..fc6bc33 --- /dev/null +++ b/tests/server/checker/runner/memory/execute.test.ts @@ -0,0 +1,184 @@ +import type { Systeminformation } from "systeminformation"; + +import { describe, expect, test } from "bun:test"; + +import type { RawTargetConfig } from "../../../../../src/server/checker/types"; + +import { MemoryChecker } from "../../../../../src/server/checker/runner/memory/execute"; + +function makeMemData(overrides: Partial = {}): Systeminformation.MemData { + return { + active: 4294967296, + available: 8589934592, + buffcache: 1073741824, + buffers: 536870912, + cached: 536870912, + dirty: null, + free: 4294967296, + reclaimable: 0, + slab: 0, + swapfree: 0, + swaptotal: 0, + swapused: 0, + total: 17179869184, + used: 8589934592, + writeback: null, + ...overrides, + }; +} + +function makeResolveContext( + overrides: Partial<{ configDir: string; defaultIntervalMs: number; defaultTimeoutMs: number }> = {}, +) { + return { + configDir: "/test", + defaultIntervalMs: 30000, + defaultTimeoutMs: 10000, + ...overrides, + }; +} + +describe("MemoryChecker resolve", () => { + const checker = new MemoryChecker(); + + test("默认值:memory 为空对象", () => { + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + expect(resolved.memory).toEqual({}); + }); + + test("无 expect 时 expect 为 undefined", () => { + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + expect(resolved.expect).toBeUndefined(); + }); + + test("保留 expect 字段", () => { + const target: RawTargetConfig = { + expect: { usagePercent: { lte: 85 } }, + id: "mem-test", + memory: {}, + type: "memory", + }; + const resolved = checker.resolve(target, makeResolveContext()); + expect(resolved.expect).toEqual({ usagePercent: { lte: 85 } }); + }); + + test("type 为 memory", () => { + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + expect(resolved.type).toBe("memory"); + }); +}); + +describe("MemoryChecker execute", () => { + test("成功匹配", async () => { + const data = makeMemData({ active: 4294967296, total: 8589934592 }); + const reader = () => Promise.resolve(data); + const checker = new MemoryChecker(reader); + + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + resolved.expect = { usagePercent: { lte: 85 } }; + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + expect(result.observation).toMatchObject({ + totalBytes: 8589934592, + usagePercent: 50, + }); + }); + + test("usagePercent mismatch", async () => { + const data = makeMemData({ active: 7730941132, total: 8589934592 }); + const reader = () => Promise.resolve(data); + const checker = new MemoryChecker(reader); + + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + resolved.expect = { usagePercent: { lte: 50 } }; + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("usage"); + }); + + test("observation 包含所有字段", async () => { + const data = makeMemData(); + const reader = () => Promise.resolve(data); + const checker = new MemoryChecker(reader); + + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + const obs = result.observation!; + expect(obs).toHaveProperty("activeBytes"); + expect(obs).toHaveProperty("activePercent"); + expect(obs).toHaveProperty("availableBytes"); + expect(obs).toHaveProperty("availablePercent"); + expect(obs).toHaveProperty("buffcacheBytes"); + expect(obs).toHaveProperty("freeBytes"); + expect(obs).toHaveProperty("freePercent"); + expect(obs).toHaveProperty("swapFreeBytes"); + expect(obs).toHaveProperty("swapTotalBytes"); + expect(obs).toHaveProperty("swapUsagePercent"); + expect(obs).toHaveProperty("swapUsedBytes"); + expect(obs).toHaveProperty("totalBytes"); + expect(obs).toHaveProperty("usagePercent"); + expect(obs).toHaveProperty("usedBytes"); + expect(obs).toHaveProperty("usedPercent"); + }); + + test("reader reject 返回失败结果", async () => { + const reader = () => Promise.reject(new Error("read error")); + const checker = new MemoryChecker(reader); + + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("memory"); + expect(result.failure?.path).toBe("snapshot"); + expect(result.observation).toBeNull(); + }); + + test("detail 格式", async () => { + const data = makeMemData({ active: 4294967296, total: 8589934592 }); + const reader = () => Promise.resolve(data); + const checker = new MemoryChecker(reader); + + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + const detail = checker.buildDetail(result.observation!); + expect(detail).toContain("usage"); + expect(detail).toContain("%"); + expect(detail).toContain("total"); + }); +}); + +describe("MemoryChecker serialize", () => { + test("序列化输出", () => { + const checker = new MemoryChecker(); + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + const result = checker.serialize(resolved); + expect(result.target).toBe("memory"); + const config = JSON.parse(result.config) as Record; + expect(config).toEqual({}); + }); +}); diff --git a/tests/server/checker/runner/memory/expect.test.ts b/tests/server/checker/runner/memory/expect.test.ts new file mode 100644 index 0000000..3c1e7cc --- /dev/null +++ b/tests/server/checker/runner/memory/expect.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; + +import { + checkActiveBytes, + checkActivePercent, + checkAvailableBytes, + checkAvailablePercent, + checkBuffcacheBytes, + checkFreeBytes, + checkFreePercent, + checkSwapFreeBytes, + checkSwapTotalBytes, + checkSwapUsagePercent, + checkSwapUsedBytes, + checkTotalBytes, + checkUsagePercent, + checkUsedBytes, + checkUsedPercent, +} from "../../../../../src/server/checker/runner/memory/expect"; + +describe("Memory expect checks - 百分比字段", () => { + test("checkUsagePercent 匹配", () => { + expect(checkUsagePercent(50, { lte: 85 }).matched).toBe(true); + }); + + test("checkUsagePercent 不匹配", () => { + const result = checkUsagePercent(90, { lte: 85 }); + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("usage"); + }); + + test("checkUsedPercent 匹配", () => { + expect(checkUsedPercent(75, { lte: 80 }).matched).toBe(true); + }); + + test("checkUsedPercent 不匹配", () => { + const result = checkUsedPercent(85, { lte: 80 }); + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("used"); + }); + + test("checkFreePercent 匹配", () => { + expect(checkFreePercent(25, { gte: 15 }).matched).toBe(true); + }); + + test("checkFreePercent 不匹配", () => { + const result = checkFreePercent(10, { gte: 15 }); + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("free"); + }); + + test("checkActivePercent 匹配", () => { + expect(checkActivePercent(50, { lte: 85 }).matched).toBe(true); + }); + + test("checkAvailablePercent 匹配", () => { + expect(checkAvailablePercent(50, { gte: 20 }).matched).toBe(true); + }); + + test("undefined matcher 直接通过", () => { + expect(checkUsagePercent(99.9, undefined).matched).toBe(true); + expect(checkFreePercent(0, undefined).matched).toBe(true); + }); +}); + +describe("Memory expect checks - 字节字段", () => { + test("checkActiveBytes 匹配", () => { + expect(checkActiveBytes(4294967296, { lte: 8589934592 }).matched).toBe(true); + }); + + test("checkUsedBytes 不匹配", () => { + const result = checkUsedBytes(10737418240, { lte: 8589934592 }); + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("usedBytes"); + }); + + test("checkFreeBytes 匹配", () => { + expect(checkFreeBytes(4294967296, { gte: 2147483648 }).matched).toBe(true); + }); + + test("checkAvailableBytes 匹配", () => { + expect(checkAvailableBytes(6442450944, { gte: 4294967296 }).matched).toBe(true); + }); + + test("checkTotalBytes 匹配", () => { + expect(checkTotalBytes(17179869184, { gte: 8589934592 }).matched).toBe(true); + }); +}); + +describe("Memory expect checks - swap 字段", () => { + test("checkSwapUsagePercent null 通过 gte 检查 (Number(null)=0)", () => { + expect(checkSwapUsagePercent(null, { gte: 0 }).matched).toBe(true); + }); + + test("checkSwapUsagePercent null 不匹配大于 0 的 gte", () => { + const result = checkSwapUsagePercent(null, { gte: 1 }); + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("swapUsage"); + }); + + test("checkSwapUsagePercent 有值时正常匹配", () => { + expect(checkSwapUsagePercent(50, { lte: 80 }).matched).toBe(true); + }); + + test("checkSwapUsagePercent 有值时不匹配", () => { + const result = checkSwapUsagePercent(90, { lte: 80 }); + expect(result.matched).toBe(false); + }); + + test("checkSwapUsedBytes null 通过 gte:0 (Number(null)=0)", () => { + expect(checkSwapUsedBytes(null, { gte: 0 }).matched).toBe(true); + }); + + test("checkSwapUsedBytes 0 通过 gte:0", () => { + expect(checkSwapUsedBytes(0, { gte: 0 }).matched).toBe(true); + }); + + test("checkSwapFreeBytes null 通过 gte:0", () => { + expect(checkSwapFreeBytes(null, { gte: 0 }).matched).toBe(true); + }); + + test("checkSwapTotalBytes null 匹配 equals:null", () => { + expect(checkSwapTotalBytes(null, { equals: null }).matched).toBe(true); + }); + + test("checkSwapTotalBytes 0 匹配 equals:0", () => { + expect(checkSwapTotalBytes(0, { equals: 0 }).matched).toBe(true); + }); + + test("checkSwapTotalBytes null 不匹配 equals:0", () => { + expect(checkSwapTotalBytes(null, { equals: 0 }).matched).toBe(false); + }); +}); + +describe("Memory expect checks - buffcacheBytes", () => { + test("checkBuffcacheBytes 有值时匹配", () => { + expect(checkBuffcacheBytes(1073741824, { lte: 2147483648 }).matched).toBe(true); + }); + + test("checkBuffcacheBytes null 通过 gte:0 (Number(null)=0)", () => { + expect(checkBuffcacheBytes(null, { gte: 0 }).matched).toBe(true); + }); + + test("checkBuffcacheBytes null 不匹配 gte:1", () => { + const result = checkBuffcacheBytes(null, { gte: 1 }); + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("buffcacheBytes"); + }); +}); diff --git a/tests/server/checker/runner/memory/normalize.test.ts b/tests/server/checker/runner/memory/normalize.test.ts new file mode 100644 index 0000000..b0411b1 --- /dev/null +++ b/tests/server/checker/runner/memory/normalize.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; + +import { normalizeTargetExpect } from "../../../../../src/server/checker/runner/memory/normalize"; + +describe("normalizeTargetExpect (memory)", () => { + test("无 expect 直接返回", () => { + const target = { id: "test", memory: {}, type: "memory" }; + expect(normalizeTargetExpect(target)).toEqual(target); + }); + + test("expect 为非对象直接返回", () => { + const target = { expect: "not-an-object", id: "test", memory: {}, type: "memory" }; + expect(normalizeTargetExpect(target)).toEqual(target); + }); + + test("字节大小字符串 512MB 转换为数字", () => { + const target = { expect: { usedBytes: "512MB" }, id: "test", memory: {}, type: "memory" }; + const result = normalizeTargetExpect(target); + expect((result.expect as Record)["usedBytes"]).toEqual({ equals: 536870912 }); + }); + + test("字节大小字符串 1GB 转换为数字", () => { + const target = { expect: { totalBytes: "1GB" }, id: "test", memory: {}, type: "memory" }; + const result = normalizeTargetExpect(target); + expect((result.expect as Record)["totalBytes"]).toEqual({ equals: 1073741824 }); + }); + + test("数字字节 matcher 保持不变", () => { + const target = { expect: { usedBytes: 1073741824 }, id: "test", memory: {}, type: "memory" }; + const result = normalizeTargetExpect(target); + expect((result.expect as Record)["usedBytes"]).toEqual({ equals: 1073741824 }); + }); + + test("百分比 matcher 正常展开", () => { + const target = { expect: { usagePercent: 85 }, id: "test", memory: {}, type: "memory" }; + const result = normalizeTargetExpect(target); + expect((result.expect as Record)["usagePercent"]).toEqual({ equals: 85 }); + }); + + test("matcher 对象保持不变", () => { + const target = { expect: { usagePercent: { lte: 85 } }, id: "test", memory: {}, type: "memory" }; + const result = normalizeTargetExpect(target); + expect((result.expect as Record)["usagePercent"]).toEqual({ lte: 85 }); + }); + + test("字节 matcher 对象内字符串转换", () => { + const target = { expect: { usedBytes: { gte: "512MB" } }, id: "test", memory: {}, type: "memory" }; + const result = normalizeTargetExpect(target); + expect((result.expect as Record)["usedBytes"]).toEqual({ gte: 536870912 }); + }); + + test("多个字段同时处理", () => { + const target = { + expect: { freePercent: 25, totalBytes: "16GB", usagePercent: { lte: 85 } }, + id: "test", + memory: {}, + type: "memory", + }; + const result = normalizeTargetExpect(target); + const expectObj = result.expect as Record; + expect(expectObj["freePercent"]).toEqual({ equals: 25 }); + expect(expectObj["totalBytes"]).toEqual({ equals: 17179869184 }); + expect(expectObj["usagePercent"]).toEqual({ lte: 85 }); + }); +}); + +describe("normalizeTargetExpect (memory) 错误", () => { + test("非法大小字符串抛出", () => { + const target = { expect: { usedBytes: "abc" }, id: "test", memory: {}, type: "memory" }; + expect(() => normalizeTargetExpect(target)).toThrow(); + }); +}); diff --git a/tests/server/checker/runner/memory/schema.test.ts b/tests/server/checker/runner/memory/schema.test.ts new file mode 100644 index 0000000..e4362e4 --- /dev/null +++ b/tests/server/checker/runner/memory/schema.test.ts @@ -0,0 +1,95 @@ +import Ajv from "ajv"; +import { describe, expect, test } from "bun:test"; + +import { memoryCheckerSchemas } from "../../../../../src/server/checker/runner/memory/schema"; + +const ajv = new Ajv({ strict: false }); + +describe("Memory checker schema", () => { + test("authoring config 空配置通过", () => { + const validate = ajv.compile(memoryCheckerSchemas.authoring.config); + expect(validate({})).toBe(true); + }); + + test("normalized config 空配置通过", () => { + const validate = ajv.compile(memoryCheckerSchemas.normalized.config); + expect(validate({})).toBe(true); + }); + + test("config 拒绝额外字段", () => { + const validate = ajv.compile(memoryCheckerSchemas.authoring.config); + expect(validate({ extraField: true })).toBe(false); + }); + + test("authoring expect 允许百分比 ValueMatcher 简写", () => { + const validate = ajv.compile(memoryCheckerSchemas.authoring.expect); + expect(validate({ usagePercent: 85 })).toBe(true); + expect(validate({ usagePercent: { lte: 85 } })).toBe(true); + }); + + test("authoring expect 允许字节字段字符串", () => { + const validate = ajv.compile(memoryCheckerSchemas.authoring.expect); + expect(validate({ usedBytes: "512MB" })).toBe(true); + expect(validate({ totalBytes: "1GB" })).toBe(true); + }); + + test("authoring expect 允许字节字段数字", () => { + const validate = ajv.compile(memoryCheckerSchemas.authoring.expect); + expect(validate({ usedBytes: 536870912 })).toBe(true); + }); + + test("normalized expect 允许 matcher 对象", () => { + const validate = ajv.compile(memoryCheckerSchemas.normalized.expect); + expect(validate({ freePercent: { gte: 15 }, usagePercent: { lte: 85 } })).toBe(true); + }); + + test("expect 拒绝未知字段", () => { + const validate = ajv.compile(memoryCheckerSchemas.authoring.expect); + expect(validate({ unknownField: 1 })).toBe(false); + }); + + test("expect 空对象通过", () => { + const validate = ajv.compile(memoryCheckerSchemas.normalized.expect); + expect(validate({})).toBe(true); + }); + + test("expect 允许所有合法百分比字段", () => { + const validate = ajv.compile(memoryCheckerSchemas.normalized.expect); + expect( + validate({ + activePercent: { lte: 80 }, + availablePercent: { gte: 20 }, + freePercent: { gte: 15 }, + swapUsagePercent: { lte: 50 }, + usagePercent: { lte: 85 }, + usedPercent: { lte: 90 }, + }), + ).toBe(true); + }); + + test("expect 允许所有合法字节字段", () => { + const validate = ajv.compile(memoryCheckerSchemas.normalized.expect); + expect( + validate({ + activeBytes: { lte: 8589934592 }, + availableBytes: { gte: 4294967296 }, + freeBytes: { gte: 2147483648 }, + swapFreeBytes: { gte: 0 }, + swapTotalBytes: { lte: 4294967296 }, + swapUsedBytes: { lte: 2147483648 }, + totalBytes: { equals: 17179869184 }, + usedBytes: { lte: 8589934592 }, + }), + ).toBe(true); + }); + + test("expect 允许 durationMs 字段", () => { + const validate = ajv.compile(memoryCheckerSchemas.normalized.expect); + expect(validate({ durationMs: { lte: 5000 } })).toBe(true); + }); + + test("expect 允许 buffcacheBytes 字段", () => { + const validate = ajv.compile(memoryCheckerSchemas.normalized.expect); + expect(validate({ buffcacheBytes: { lte: 2147483648 } })).toBe(true); + }); +}); diff --git a/tests/server/checker/runner/memory/validate.test.ts b/tests/server/checker/runner/memory/validate.test.ts new file mode 100644 index 0000000..35cbb4f --- /dev/null +++ b/tests/server/checker/runner/memory/validate.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; + +import type { RawTargetConfig } from "../../../../../src/server/checker/types"; + +import { validateMemoryConfig } from "../../../../../src/server/checker/runner/memory/validate"; + +function validate(target: RawTargetConfig) { + return validateMemoryConfig({ targets: [target] }); +} + +describe("validateMemoryConfig", () => { + test("有效配置无错误", () => { + expect(validate({ id: "mem-test", memory: {}, type: "memory" })).toEqual([]); + }); + + test("缺少 memory 配置分组", () => { + const issues = validate({ id: "mem-test", type: "memory" }); + expect(issues.some((i) => i.path.endsWith("memory") && i.code === "required")).toBe(true); + }); + + test("memory 未知字段报错", () => { + const issues = validate({ id: "mem-test", memory: { extra: true }, type: "memory" }); + expect(issues.some((i) => i.path.endsWith("extra") && i.code === "unknown-field")).toBe(true); + }); + + test("expect 未知字段报错", () => { + const issues = validate({ expect: { logicalCoreCount: { gte: 4 } }, id: "mem-test", memory: {}, type: "memory" }); + expect(issues.some((i) => i.path.endsWith("logicalCoreCount") && i.code === "unknown-field")).toBe(true); + }); + + test("expect 合法 ValueMatcher 通过", () => { + const issues = validate({ + expect: { usagePercent: { lte: 85 }, usedBytes: { lte: 8589934592 } }, + id: "mem-test", + memory: {}, + type: "memory", + }); + expect(issues.filter((i) => i.path.includes("expect"))).toEqual([]); + }); + + test("expect 非法 ValueMatcher 报错", () => { + const issues = validate({ expect: { usagePercent: [1, 2] }, id: "mem-test", memory: {}, type: "memory" }); + expect(issues.some((i) => i.path.includes("usagePercent"))).toBe(true); + }); + + test("expect 合法字节大小字符串通过", () => { + const issues = validate({ expect: { usedBytes: "512MB" }, id: "mem-test", memory: {}, type: "memory" }); + expect(issues.filter((i) => i.path.includes("usedBytes"))).toEqual([]); + }); + + test("expect 非法字节大小字符串报错", () => { + const issues = validate({ expect: { usedBytes: "abc" }, id: "mem-test", memory: {}, type: "memory" }); + expect(issues.some((i) => i.path.includes("usedBytes") && i.message.includes("字节大小"))).toBe(true); + }); + + test("expect 所有合法字段通过", () => { + const issues = validate({ + expect: { + activeBytes: { lte: 8589934592 }, + activePercent: { lte: 80 }, + availableBytes: { gte: 4294967296 }, + availablePercent: { gte: 20 }, + buffcacheBytes: { lte: 2147483648 }, + durationMs: { lte: 5000 }, + freeBytes: { gte: 2147483648 }, + freePercent: { gte: 15 }, + swapFreeBytes: { gte: 0 }, + swapTotalBytes: { lte: 4294967296 }, + swapUsagePercent: { lte: 50 }, + swapUsedBytes: { lte: 2147483648 }, + totalBytes: { equals: 17179869184 }, + usagePercent: { lte: 85 }, + usedBytes: { lte: 8589934592 }, + usedPercent: { lte: 90 }, + }, + id: "mem-test", + memory: {}, + type: "memory", + }); + expect(issues).toEqual([]); + }); + + test("非 memory type 的 target 不校验", () => { + const issues = validate({ id: "other-test", type: "http" }); + expect(issues).toEqual([]); + }); +}); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index b727363..a1a02d7 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -84,9 +84,22 @@ describe("CheckerRegistry", () => { "dns", "ws", "cpu", + "memory", "custom", ]); - expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws", "cpu"]); + expect(second.supportedTypes).toEqual([ + "http", + "cmd", + "db", + "tcp", + "icmp", + "udp", + "llm", + "dns", + "ws", + "cpu", + "memory", + ]); expect( first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect), ).toBe(true);