Compare commits
5 Commits
f38286d74d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f8fd8bd9c | |||
| 3390eb5e8d | |||
| 145bb8fd04 | |||
| 358f8d011a | |||
| c2dcfab80c |
@@ -2,7 +2,10 @@
|
||||
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
|
||||
"permission": {
|
||||
"*": "allow",
|
||||
"write": "allow",
|
||||
"edit": "allow",
|
||||
"bash": {
|
||||
"*": "allow",
|
||||
"npm *": "deny",
|
||||
"npx *": "deny",
|
||||
"pnpm *": "deny",
|
||||
|
||||
5
bun.lock
5
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=="],
|
||||
|
||||
@@ -6,17 +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) |
|
||||
| 类型 | 用途 | 文档 |
|
||||
| -------- | -------------------------------------- | ------------------- |
|
||||
| `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) |
|
||||
|
||||
## 选择建议
|
||||
|
||||
@@ -31,6 +33,8 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一
|
||||
| 域名解析值、DNS RCODE、TTL、flags | `dns` |
|
||||
| LLM API 是否可用、输出是否符合预期 | `llm` |
|
||||
| WebSocket 可达性或消息交互验证 | `ws` |
|
||||
| 本机 CPU 使用率健康检查 | `cpu` |
|
||||
| 本机系统内存使用状况检查 | `memory` |
|
||||
|
||||
## 通用字段
|
||||
|
||||
|
||||
74
docs/user/checkers/cpu.md
Normal file
74
docs/user/checkers/cpu.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# CPU Checker
|
||||
|
||||
`type: cpu` 用于检查本机 CPU 使用率,基于两次系统快照计算总体和每核心的忙碌比例。
|
||||
|
||||
## 配置项
|
||||
|
||||
| 字段 | 说明 | 必填 | 默认值 |
|
||||
| -------------------- | -------------------------------- | ---- | ------- |
|
||||
| `cpu.sampleDuration` | CPU 采样窗口,支持时长格式 | 否 | `1s` |
|
||||
| `cpu.includePerCore` | 是否在结果中输出每核心使用率数组 | 否 | `false` |
|
||||
|
||||
`sampleDuration` 必须小于 target 的 `timeout`。
|
||||
|
||||
## expect 校验项
|
||||
|
||||
| 字段 | 说明 | 必填 | 默认值 |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------- | ---- | ------ |
|
||||
| `usagePercent` | 总体 CPU 使用率,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 |
|
||||
| `idlePercent` | 总体 CPU 空闲率,与 `usagePercent` 互补,两者之和恒为 100(`idlePercent + usagePercent = 100`) | 否 | 无 |
|
||||
| `maxCoreUsagePercent` | 单核心最高使用率,使用 `ValueMatcher` | 否 | 无 |
|
||||
| `minCoreUsagePercent` | 单核心最低使用率,使用 `ValueMatcher` | 否 | 无 |
|
||||
| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 |
|
||||
|
||||
所有百分比字段范围为 `0-100`,表示所有可见逻辑 CPU 的总体比例,不是"核心数 × 100"。
|
||||
|
||||
## 示例
|
||||
|
||||
```yaml
|
||||
- id: "local-cpu"
|
||||
name: "本机 CPU"
|
||||
type: cpu
|
||||
interval: "30s"
|
||||
timeout: "5s"
|
||||
cpu:
|
||||
sampleDuration: "1s"
|
||||
expect:
|
||||
usagePercent:
|
||||
lte: 85
|
||||
maxCoreUsagePercent:
|
||||
lte: 95
|
||||
```
|
||||
|
||||
输出每核心使用率:
|
||||
|
||||
```yaml
|
||||
- id: "local-cpu-detail"
|
||||
name: "本机 CPU 详细"
|
||||
type: cpu
|
||||
cpu:
|
||||
sampleDuration: "2s"
|
||||
includePerCore: true
|
||||
expect:
|
||||
usagePercent:
|
||||
lte: 80
|
||||
```
|
||||
|
||||
## 语义说明
|
||||
|
||||
CPU checker 采集的是 DiAL 进程运行环境通过系统 API(`os.cpus()`)可见的 CPU 视图。在容器中,它可能不等于严格的 cgroup quota 使用率。
|
||||
|
||||
`usagePercent` 和 `idlePercent` 互补,恒等于 100。`sampleDuration` 决定了两次快照之间的等待时间,窗口越长结果越稳定,但会增加 checker 执行耗时。
|
||||
|
||||
## 不支持的功能
|
||||
|
||||
- CPU 温度、电源状态、频率
|
||||
- `userPercent` / `systemPercent`(用户态/系统态占比)
|
||||
- `loadAverage`(系统负载均值)
|
||||
- 进程级 CPU 使用率
|
||||
- Linux cgroup 精确 CPU 计算
|
||||
- `logicalCoreCount` 作为 expect 字段(仅在 observation 中输出)
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改 CPU checker 配置、expect 字段、行为或语义时,必须更新本文档。
|
||||
119
docs/user/checkers/mem.md
Normal file
119
docs/user/checkers/mem.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Mem Checker
|
||||
|
||||
`type: mem` 用于检查本机系统级内存使用状况,包括物理内存和交换空间的使用率及字节数。
|
||||
|
||||
## 配置项
|
||||
|
||||
Mem checker 配置为空对象,无需额外参数:
|
||||
|
||||
```yaml
|
||||
mem: {}
|
||||
```
|
||||
|
||||
## 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: mem
|
||||
interval: "30s"
|
||||
timeout: "5s"
|
||||
mem: {}
|
||||
expect:
|
||||
usagePercent:
|
||||
lte: 85
|
||||
```
|
||||
|
||||
检查可用内存不低于 4GB:
|
||||
|
||||
```yaml
|
||||
- id: "local-memory-available"
|
||||
name: "可用内存检查"
|
||||
type: mem
|
||||
mem: {}
|
||||
expect:
|
||||
availableBytes:
|
||||
gte: "4GB"
|
||||
```
|
||||
|
||||
同时检查内存和交换空间:
|
||||
|
||||
```yaml
|
||||
- id: "local-memory-swap"
|
||||
name: "内存和交换空间"
|
||||
type: mem
|
||||
mem: {}
|
||||
expect:
|
||||
usagePercent:
|
||||
lte: 80
|
||||
swapUsagePercent:
|
||||
lte: 50
|
||||
```
|
||||
|
||||
## 语义说明
|
||||
|
||||
Mem 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`。
|
||||
|
||||
Mem checker 是即时读取(非采样),无需 `sampleDuration`,执行速度远快于 CPU checker。虽然读取本身很快,但仍受 target `timeout` 约束——若底层系统调用悬挂或阻塞超过 `timeout`,checker 会返回 `mem/timeout` failure。
|
||||
|
||||
## 跨平台注意事项
|
||||
|
||||
- Windows 环境依赖 PowerShell 5+ 获取部分内存指标
|
||||
- `buffcacheBytes` 在非 Linux 平台上可能返回 `null`
|
||||
- 容器环境中内存数据可能不反映 cgroup 内存限制
|
||||
|
||||
## 不支持的功能
|
||||
|
||||
- 进程级内存使用(如 RSS、VSZ)
|
||||
- cgroup/container 内存限制精度
|
||||
- 内存趋势采样和历史记录
|
||||
- 内存条物理布局信息
|
||||
- 详细内存分类(slab、reclaimable、dirty 等)作为 expect 字段
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改 Mem checker 配置、expect 字段、行为或语义时,必须更新本文档。
|
||||
@@ -121,7 +121,7 @@ targets:
|
||||
| `id` | 目标唯一标识,最长 30 字符,支持字母数字、下划线、连字符,不参与变量替换 | 是 | 无 |
|
||||
| `name` | 展示名称,最长 30 字符,支持变量替换,可省略或显式 null;前端展示时 null 回退到 `id` | 否 | 无 |
|
||||
| `description` | 目标描述,最长 500 字符,支持变量替换,可省略或显式 null,允许空字符串 | 否 | 无 |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm`、`ws` | 是 | 无 |
|
||||
| `type` | 目标类型:`http`、`cmd`、`db`、`tcp`、`udp`、`dns`、`icmp`、`llm`、`ws`、`cpu` | 是 | 无 |
|
||||
| `group` | 分组名称 | 否 | `default` |
|
||||
| `interval` | 拨测间隔,最小 `10s` | 否 | `30s` |
|
||||
| `timeout` | 超时时间,必须小于等于 `interval` | 否 | `10s` |
|
||||
|
||||
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -353,3 +353,28 @@ targets:
|
||||
- contains: "hello"
|
||||
durationMs:
|
||||
lte: 5000
|
||||
|
||||
- id: "local-cpu"
|
||||
name: "本机 CPU"
|
||||
type: cpu
|
||||
group: "基础设施"
|
||||
interval: "30s"
|
||||
timeout: "5s"
|
||||
cpu:
|
||||
sampleDuration: "1s"
|
||||
expect:
|
||||
usagePercent:
|
||||
lte: 85
|
||||
maxCoreUsagePercent:
|
||||
lte: 95
|
||||
|
||||
- id: "local-memory"
|
||||
name: "本机内存"
|
||||
type: mem
|
||||
group: "基础设施"
|
||||
interval: "30s"
|
||||
timeout: "5s"
|
||||
mem: {}
|
||||
expect:
|
||||
usagePercent:
|
||||
lte: 85
|
||||
|
||||
111
src/server/checker/runner/cpu/calculate.ts
Normal file
111
src/server/checker/runner/cpu/calculate.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { cpus } from "node:os";
|
||||
|
||||
import type { CpuCoreSnapshot, CpuStats } from "./types";
|
||||
|
||||
/**
|
||||
* 根据两次 CPU times 快照计算使用率统计。
|
||||
*
|
||||
* - usagePercent = 100 - idlePercent(互补关系,恒等于 100)
|
||||
* - idlePercent = 所有核心 idle delta 之和 ÷ 所有核心 total delta 之和 × 100
|
||||
* - maxCoreUsagePercent / minCoreUsagePercent 为单核心粒度的最高/最低使用率
|
||||
* - 所有百分比范围 0-100,保留 1 位小数
|
||||
*/
|
||||
export function calculateCpuStats(before: CpuCoreSnapshot[], after: CpuCoreSnapshot[]): CpuStats {
|
||||
let totalIdleDelta = 0;
|
||||
let totalDelta = 0;
|
||||
const perCoreUsage: number[] = [];
|
||||
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const b = before[i]!.times;
|
||||
const a = after[i]!.times;
|
||||
|
||||
const idleDelta = a.idle - b.idle;
|
||||
const userDelta = a.user - b.user;
|
||||
const niceDelta = a.nice - b.nice;
|
||||
const sysDelta = a.sys - b.sys;
|
||||
const irqDelta = a.irq - b.irq;
|
||||
|
||||
const coreTotalDelta = userDelta + niceDelta + sysDelta + idleDelta + irqDelta;
|
||||
const coreIdleDelta = idleDelta;
|
||||
|
||||
totalIdleDelta += coreIdleDelta;
|
||||
totalDelta += coreTotalDelta;
|
||||
|
||||
const coreUsagePercent = coreTotalDelta === 0 ? 0 : round1((1 - coreIdleDelta / coreTotalDelta) * 100);
|
||||
perCoreUsage.push(coreUsagePercent);
|
||||
}
|
||||
|
||||
const idlePercent = totalDelta === 0 ? 0 : round1((totalIdleDelta / totalDelta) * 100);
|
||||
const usagePercent = totalDelta === 0 ? 0 : round1(100 - idlePercent);
|
||||
|
||||
const maxCoreUsagePercent = Math.max(...perCoreUsage);
|
||||
const minCoreUsagePercent = Math.min(...perCoreUsage);
|
||||
|
||||
return {
|
||||
idlePercent,
|
||||
logicalCoreCount: before.length,
|
||||
maxCoreUsagePercent,
|
||||
minCoreUsagePercent,
|
||||
perCoreUsagePercent: perCoreUsage,
|
||||
usagePercent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前 CPU 各核心 times 快照。
|
||||
* 委托给 node:os 的 os.cpus(),便于测试时注入 mock。
|
||||
*/
|
||||
export function readCpuSnapshot(): CpuCoreSnapshot[] {
|
||||
return cpus().map((cpu) => ({
|
||||
times: {
|
||||
idle: cpu.times.idle,
|
||||
irq: cpu.times.irq,
|
||||
nice: cpu.times.nice,
|
||||
sys: cpu.times.sys,
|
||||
user: cpu.times.user,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateCpuSnapshots(before: CpuCoreSnapshot[], after: CpuCoreSnapshot[]): null | string {
|
||||
if (before.length === 0 || after.length === 0) {
|
||||
return "CPU 快照为空";
|
||||
}
|
||||
|
||||
if (before.length !== after.length) {
|
||||
return `CPU 快照核心数不一致: before=${before.length}, after=${after.length}`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const bTimes = before[i]!.times;
|
||||
const aTimes = after[i]!.times;
|
||||
|
||||
for (const [name, value] of Object.entries(bTimes)) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return `CPU 快照包含非有限值: before[${i}].times.${name}=${value}`;
|
||||
}
|
||||
}
|
||||
for (const [name, value] of Object.entries(aTimes)) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return `CPU 快照包含非有限值: after[${i}].times.${name}=${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
const idleDelta = aTimes.idle - bTimes.idle;
|
||||
const userDelta = aTimes.user - bTimes.user;
|
||||
const niceDelta = aTimes.nice - bTimes.nice;
|
||||
const sysDelta = aTimes.sys - bTimes.sys;
|
||||
const irqDelta = aTimes.irq - bTimes.irq;
|
||||
const coreTotalDelta = userDelta + niceDelta + sysDelta + idleDelta + irqDelta;
|
||||
|
||||
if (coreTotalDelta < 0) {
|
||||
return `CPU 快照包含负数 delta: core[${i}] totalDelta=${coreTotalDelta}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
219
src/server/checker/runner/cpu/execute.ts
Normal file
219
src/server/checker/runner/cpu/execute.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { CpuCoreSnapshot, CpuStats, CpuTargetConfig, ResolvedCpuExpectConfig, ResolvedCpuTarget } from "./types";
|
||||
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { parseDuration } from "../../utils";
|
||||
import { calculateCpuStats, readCpuSnapshot, validateCpuSnapshots } from "./calculate";
|
||||
import { checkIdlePercent, checkMaxCoreUsage, checkMinCoreUsage, checkUsagePercent } from "./expect";
|
||||
import { normalizeTargetExpect } from "./normalize";
|
||||
import { cpuCheckerSchemas } from "./schema";
|
||||
import { validateCpuConfig } from "./validate";
|
||||
|
||||
const DEFAULT_SAMPLE_DURATION_MS = 1000;
|
||||
|
||||
/**
|
||||
* 可注入的 CPU 快照读取函数,便于测试。
|
||||
* 生产环境使用 node:os 的 os.cpus()。
|
||||
*/
|
||||
export type SnapshotReader = () => CpuCoreSnapshot[];
|
||||
|
||||
export class CpuChecker implements CheckerDefinition<ResolvedCpuTarget> {
|
||||
readonly configKey = "cpu";
|
||||
|
||||
readonly schemas = cpuCheckerSchemas;
|
||||
|
||||
readonly type = "cpu";
|
||||
|
||||
constructor(private readonly readSnapshot: SnapshotReader = readCpuSnapshot) {}
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): null | string {
|
||||
const usage = observation["usagePercent"];
|
||||
const usageStr = typeof usage === "number" ? formatNumber(usage) : "n/a";
|
||||
const maxCore = observation["maxCoreUsagePercent"];
|
||||
const maxStr = typeof maxCore === "number" ? formatNumber(maxCore) : "n/a";
|
||||
const cores = observation["logicalCoreCount"];
|
||||
const coresStr = typeof cores === "number" ? String(cores) : "?";
|
||||
return `usage ${usageStr}%, max core ${maxStr}%, ${coresStr} cores`;
|
||||
}
|
||||
|
||||
async execute(t: ResolvedCpuTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
let before: CpuCoreSnapshot[];
|
||||
try {
|
||||
before = this.readSnapshot();
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"cpu",
|
||||
"snapshot",
|
||||
`CPU 快照读取失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// 采样等待,支持 AbortSignal 取消
|
||||
const aborted = await waitForDuration(t.cpu.sampleDurationMs, ctx.signal);
|
||||
|
||||
let after: CpuCoreSnapshot[];
|
||||
if (aborted) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("cpu", "timeout", `CPU 采样超时 (${t.timeoutMs}ms)`),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
after = this.readSnapshot();
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure(
|
||||
"cpu",
|
||||
"snapshot",
|
||||
`CPU 快照读取失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const validationError = validateCpuSnapshots(before, after);
|
||||
if (validationError !== null) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("cpu", "snapshot", validationError),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const stats = calculateCpuStats(before, after);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const result = checkStats(stats, t.expect, durationMs);
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
error: null,
|
||||
idlePercent: stats.idlePercent,
|
||||
logicalCoreCount: stats.logicalCoreCount,
|
||||
maxCoreUsagePercent: stats.maxCoreUsagePercent,
|
||||
minCoreUsagePercent: stats.minCoreUsagePercent,
|
||||
usagePercent: stats.usagePercent,
|
||||
};
|
||||
|
||||
if (t.cpu.includePerCore) {
|
||||
observation["perCoreUsagePercent"] = stats.perCoreUsagePercent;
|
||||
}
|
||||
|
||||
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): ResolvedCpuTarget {
|
||||
const t = target as RawTargetConfig & { cpu: CpuTargetConfig; type: "cpu" };
|
||||
|
||||
const rawSampleDuration = t.cpu.sampleDuration;
|
||||
const sampleDurationMs = rawSampleDuration ? parseDuration(rawSampleDuration) : DEFAULT_SAMPLE_DURATION_MS;
|
||||
const includePerCore = t.cpu.includePerCore ?? false;
|
||||
|
||||
return {
|
||||
cpu: { includePerCore, sampleDurationMs },
|
||||
description: null,
|
||||
expect: target.expect as ResolvedCpuExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
id: t.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
name: t.name ?? null,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "cpu",
|
||||
} satisfies ResolvedCpuTarget;
|
||||
}
|
||||
|
||||
serialize(t: ResolvedCpuTarget): { config: string; target: string } {
|
||||
return {
|
||||
config: JSON.stringify(t.cpu),
|
||||
target: `cpu sample ${t.cpu.sampleDurationMs}ms`,
|
||||
};
|
||||
}
|
||||
|
||||
validate(input: CheckerValidationInput) {
|
||||
return validateCpuConfig(input);
|
||||
}
|
||||
}
|
||||
|
||||
function checkStats(stats: CpuStats, expect: ResolvedCpuExpectConfig | undefined, durationMs: number) {
|
||||
const usageResult = checkUsagePercent(stats.usagePercent, expect?.usagePercent);
|
||||
if (!usageResult.matched) return usageResult;
|
||||
const idleResult = checkIdlePercent(stats.idlePercent, expect?.idlePercent);
|
||||
if (!idleResult.matched) return idleResult;
|
||||
const maxCoreResult = checkMaxCoreUsage(stats.maxCoreUsagePercent, expect?.maxCoreUsagePercent);
|
||||
if (!maxCoreResult.matched) return maxCoreResult;
|
||||
const minCoreResult = checkMinCoreUsage(stats.minCoreUsagePercent, expect?.minCoreUsagePercent);
|
||||
if (!minCoreResult.matched) return minCoreResult;
|
||||
return checkValueExpectation(durationMs, expect?.durationMs, {
|
||||
message: "durationMs mismatch",
|
||||
path: "durationMs",
|
||||
phase: "duration",
|
||||
});
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待指定毫秒,支持 AbortSignal 取消。
|
||||
* 返回 true 表示被中断(aborted),false 表示正常完成。
|
||||
*/
|
||||
async function waitForDuration(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) return true;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(false);
|
||||
}, ms);
|
||||
|
||||
function onAbort() {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
35
src/server/checker/runner/cpu/expect.ts
Normal file
35
src/server/checker/runner/cpu/expect.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ExpectationResult, ValueExpectation } from "../../expect/types";
|
||||
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
|
||||
export function checkIdlePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "CPU 空闲率不满足条件",
|
||||
path: "idlePercent",
|
||||
phase: "idle",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkMaxCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "单核心最大使用率不满足条件",
|
||||
path: "maxCoreUsagePercent",
|
||||
phase: "maxCoreUsage",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkMinCoreUsage(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "单核心最小使用率不满足条件",
|
||||
path: "minCoreUsagePercent",
|
||||
phase: "minCoreUsage",
|
||||
});
|
||||
}
|
||||
|
||||
export function checkUsagePercent(actual: number, matcher: undefined | ValueExpectation): ExpectationResult {
|
||||
return checkValueExpectation(actual, matcher, {
|
||||
message: "CPU 使用率不满足条件",
|
||||
path: "usagePercent",
|
||||
phase: "usage",
|
||||
});
|
||||
}
|
||||
1
src/server/checker/runner/cpu/index.ts
Normal file
1
src/server/checker/runner/cpu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CpuChecker } from "./execute";
|
||||
20
src/server/checker/runner/cpu/normalize.ts
Normal file
20
src/server/checker/runner/cpu/normalize.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { RawTargetConfig } from "../../types";
|
||||
|
||||
import { compactExpect, normalizeValue } from "../../expect/normalize";
|
||||
|
||||
export function normalizeTargetExpect(target: RawTargetConfig): RawTargetConfig {
|
||||
if (target.expect === undefined || !isPlainObject(target.expect)) return target;
|
||||
const raw = target.expect as Record<string, unknown>;
|
||||
return {
|
||||
...target,
|
||||
expect: compactExpect(raw, {
|
||||
durationMs: normalizeValue(raw["durationMs"]),
|
||||
idlePercent: normalizeValue(raw["idlePercent"]),
|
||||
maxCoreUsagePercent: normalizeValue(raw["maxCoreUsagePercent"]),
|
||||
minCoreUsagePercent: normalizeValue(raw["minCoreUsagePercent"]),
|
||||
usagePercent: normalizeValue(raw["usagePercent"]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
46
src/server/checker/runner/cpu/schema.ts
Normal file
46
src/server/checker/runner/cpu/schema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
durationSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const cpuCheckerSchemas: CheckerSchemas = {
|
||||
authoring: {
|
||||
config: createCpuConfigSchema("authoring"),
|
||||
expect: createCpuExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createCpuConfigSchema("normalized"),
|
||||
expect: createCpuExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createCpuConfigSchema(kind: "authoring" | "normalized") {
|
||||
return Type.Object(
|
||||
{
|
||||
includePerCore: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(Type.Boolean()) : Type.Boolean()),
|
||||
sampleDuration: Type.Optional(kind === "authoring" ? createAuthoringFieldSchema(durationSchema) : durationSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
function createCpuExpectSchema(kind: "authoring" | "normalized") {
|
||||
const valueSchema =
|
||||
kind === "authoring" ? createAuthoringValueExpectationSchema() : createNormalizedValueExpectationSchema();
|
||||
return Type.Object(
|
||||
{
|
||||
durationMs: Type.Optional(valueSchema),
|
||||
idlePercent: Type.Optional(valueSchema),
|
||||
maxCoreUsagePercent: Type.Optional(valueSchema),
|
||||
minCoreUsagePercent: Type.Optional(valueSchema),
|
||||
usagePercent: Type.Optional(valueSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
59
src/server/checker/runner/cpu/types.ts
Normal file
59
src/server/checker/runner/cpu/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export interface CpuCoreSnapshot {
|
||||
times: CpuTimesSnapshot;
|
||||
}
|
||||
|
||||
export interface CpuStats {
|
||||
idlePercent: number;
|
||||
logicalCoreCount: number;
|
||||
maxCoreUsagePercent: number;
|
||||
minCoreUsagePercent: number;
|
||||
perCoreUsagePercent: number[];
|
||||
usagePercent: number;
|
||||
}
|
||||
|
||||
export interface CpuTargetConfig {
|
||||
includePerCore?: boolean;
|
||||
sampleDuration?: string;
|
||||
}
|
||||
|
||||
export interface CpuTimesSnapshot {
|
||||
idle: number;
|
||||
irq: number;
|
||||
nice: number;
|
||||
sys: number;
|
||||
user: number;
|
||||
}
|
||||
|
||||
export interface RawCpuExpectConfig {
|
||||
durationMs?: RawValueExpectation;
|
||||
idlePercent?: RawValueExpectation;
|
||||
maxCoreUsagePercent?: RawValueExpectation;
|
||||
minCoreUsagePercent?: RawValueExpectation;
|
||||
usagePercent?: RawValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedCpuConfig {
|
||||
includePerCore: boolean;
|
||||
sampleDurationMs: number;
|
||||
}
|
||||
|
||||
export interface ResolvedCpuExpectConfig {
|
||||
durationMs?: ValueExpectation;
|
||||
idlePercent?: ValueExpectation;
|
||||
maxCoreUsagePercent?: ValueExpectation;
|
||||
minCoreUsagePercent?: ValueExpectation;
|
||||
usagePercent?: ValueExpectation;
|
||||
}
|
||||
|
||||
export interface ResolvedCpuTarget extends ResolvedTargetBase {
|
||||
cpu: ResolvedCpuConfig;
|
||||
expect?: ResolvedCpuExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
name: null | string;
|
||||
timeoutMs: number;
|
||||
type: "cpu";
|
||||
}
|
||||
136
src/server/checker/runner/cpu/validate.ts
Normal file
136
src/server/checker/runner/cpu/validate.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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 { parseDuration } from "../../utils";
|
||||
|
||||
export function validateCpuConfig(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"] !== "cpu") continue;
|
||||
issues.push(...validateCpuTarget(target, `targets[${i}]`));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
if (isString(target["name"])) return target["name"];
|
||||
return isString(target["id"]) ? target["id"] : undefined;
|
||||
}
|
||||
|
||||
function validateCpuExpect(target: Record<string, unknown>, 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");
|
||||
|
||||
const valueFields = ["durationMs", "idlePercent", "maxCoreUsagePercent", "minCoreUsagePercent", "usagePercent"];
|
||||
for (const key of valueFields) {
|
||||
if (expect[key] !== undefined) {
|
||||
issues.push(...validateRawValueExpectation(expect[key], joinPath(expectPath, key), targetName));
|
||||
}
|
||||
}
|
||||
|
||||
const allowedKeys = new Set(valueFields);
|
||||
for (const key of Object.keys(expect)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateCpuTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
|
||||
// 校验 cpu 配置段
|
||||
const rawCpu = target["cpu"];
|
||||
if (!isPlainRecord(rawCpu)) {
|
||||
issues.push(issue("required", joinPath(path, "cpu"), "缺少 cpu 配置分组", targetName));
|
||||
} else {
|
||||
// 校验 sampleDuration 格式
|
||||
if (rawCpu["sampleDuration"] !== undefined) {
|
||||
const sd = rawCpu["sampleDuration"];
|
||||
if (isString(sd)) {
|
||||
try {
|
||||
parseDuration(sd);
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(joinPath(path, "cpu"), "sampleDuration"),
|
||||
"sampleDuration 不是有效的时长格式",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 变量引用时跳过格式校验(authoring 形态允许 "${...}")
|
||||
}
|
||||
|
||||
// 校验 sampleDuration < timeout(仅当两者都可解析为数值时)
|
||||
if (isString(rawCpu["sampleDuration"])) {
|
||||
try {
|
||||
const sampleMs = parseDuration(rawCpu["sampleDuration"]);
|
||||
const timeout = target["timeout"];
|
||||
if (isString(timeout)) {
|
||||
try {
|
||||
const timeoutMs = parseDuration(timeout);
|
||||
if (sampleMs >= timeoutMs) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(joinPath(path, "cpu"), "sampleDuration"),
|
||||
"sampleDuration 必须小于 timeout",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// timeout 无法解析,由通用校验处理
|
||||
}
|
||||
}
|
||||
// timeout 为 undefined 时使用默认值 10s
|
||||
if (timeout === undefined && sampleMs >= 10000) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-value",
|
||||
joinPath(joinPath(path, "cpu"), "sampleDuration"),
|
||||
"sampleDuration 必须小于 timeout(默认 10s)",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// sampleDuration 无法解析,已由上方格式校验处理
|
||||
}
|
||||
}
|
||||
|
||||
if (rawCpu["includePerCore"] !== undefined && typeof rawCpu["includePerCore"] !== "boolean") {
|
||||
issues.push(issue("invalid-type", joinPath(joinPath(path, "cpu"), "includePerCore"), "必须为布尔值", targetName));
|
||||
}
|
||||
|
||||
const allowedCpuKeys = new Set(["includePerCore", "sampleDuration"]);
|
||||
for (const key of Object.keys(rawCpu)) {
|
||||
if (!allowedCpuKeys.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(joinPath(path, "cpu"), key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 校验 expect 字段
|
||||
issues.push(...validateCpuExpect(target, path));
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CommandChecker } from "./cmd";
|
||||
import { CpuChecker } from "./cpu";
|
||||
import { DbChecker } from "./db";
|
||||
import { DnsChecker } from "./dns";
|
||||
import { HttpChecker } from "./http";
|
||||
import { IcmpChecker } from "./icmp";
|
||||
import { LlmChecker } from "./llm";
|
||||
import { MemChecker } from "./mem";
|
||||
import { CheckerRegistry } from "./registry";
|
||||
import { TcpChecker } from "./tcp";
|
||||
import { UdpChecker } from "./udp";
|
||||
@@ -19,6 +21,8 @@ const checkers = [
|
||||
new LlmChecker(),
|
||||
new DnsChecker(),
|
||||
new WsChecker(),
|
||||
new CpuChecker(),
|
||||
new MemChecker(),
|
||||
];
|
||||
|
||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||
|
||||
60
src/server/checker/runner/mem/calculate.ts
Normal file
60
src/server/checker/runner/mem/calculate.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Systeminformation } from "systeminformation";
|
||||
|
||||
import type { MemStats } from "./types";
|
||||
|
||||
export function calculateMemStats(data: Systeminformation.MemData): MemStats {
|
||||
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 readMemData(): Promise<Systeminformation.MemData> {
|
||||
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;
|
||||
}
|
||||
232
src/server/checker/runner/mem/execute.ts
Normal file
232
src/server/checker/runner/mem/execute.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { Systeminformation } from "systeminformation";
|
||||
|
||||
import type { CheckResult, RawTargetConfig } from "../../types";
|
||||
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
||||
import type { MemStats, ResolvedMemExpectConfig, ResolvedMemTarget } from "./types";
|
||||
|
||||
import { errorFailure } from "../../expect/failure";
|
||||
import { checkValueExpectation } from "../../expect/value";
|
||||
import { calculateMemStats, readMemData } 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 { memCheckerSchemas } from "./schema";
|
||||
import { validateMemConfig } from "./validate";
|
||||
|
||||
export class MemChecker implements CheckerDefinition<ResolvedMemTarget> {
|
||||
readonly configKey = "mem";
|
||||
|
||||
readonly schemas = memCheckerSchemas;
|
||||
|
||||
readonly type = "mem";
|
||||
|
||||
constructor(private readonly reader: () => Promise<Systeminformation.MemData> = readMemData) {}
|
||||
|
||||
buildDetail(observation: Record<string, unknown>): 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: ResolvedMemTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const start = performance.now();
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: errorFailure("mem", "timeout", "内存读取超时:signal 已取消"),
|
||||
matched: false,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
let data: Systeminformation.MemData;
|
||||
try {
|
||||
data = await raceWithSignal(this.reader(), ctx.signal);
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
const isTimeout =
|
||||
error instanceof AbortError || (error instanceof Error && error.message === MEM_TIMEOUT_MESSAGE);
|
||||
return {
|
||||
detail: null,
|
||||
durationMs,
|
||||
failure: isTimeout
|
||||
? errorFailure("mem", "timeout", "内存读取超时")
|
||||
: errorFailure(
|
||||
"mem",
|
||||
"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 = calculateMemStats(data);
|
||||
const result = checkStats(stats, t.expect, durationMs);
|
||||
|
||||
const observation: Record<string, unknown> = {
|
||||
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): ResolvedMemTarget {
|
||||
return {
|
||||
description: null,
|
||||
expect: target.expect as ResolvedMemExpectConfig | undefined,
|
||||
group: target.group ?? "default",
|
||||
id: target.id,
|
||||
intervalMs: context.defaultIntervalMs,
|
||||
mem: {},
|
||||
name: target.name ?? null,
|
||||
timeoutMs: context.defaultTimeoutMs,
|
||||
type: "mem",
|
||||
} satisfies ResolvedMemTarget;
|
||||
}
|
||||
|
||||
serialize(t: ResolvedMemTarget): { config: string; target: string } {
|
||||
return {
|
||||
config: JSON.stringify(t.mem),
|
||||
target: `mem`,
|
||||
};
|
||||
}
|
||||
|
||||
validate(input: CheckerValidationInput) {
|
||||
return validateMemConfig(input);
|
||||
}
|
||||
}
|
||||
|
||||
function checkStats(stats: MemStats, expect: ResolvedMemExpectConfig | 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)));
|
||||
}
|
||||
|
||||
const MEM_TIMEOUT_MESSAGE = "Memory read aborted by signal";
|
||||
|
||||
class AbortError extends Error {
|
||||
constructor() {
|
||||
super(MEM_TIMEOUT_MESSAGE);
|
||||
this.name = "AbortError";
|
||||
}
|
||||
}
|
||||
|
||||
function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(new AbortError());
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
function onAbort() {
|
||||
reject(new AbortError());
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
123
src/server/checker/runner/mem/expect.ts
Normal file
123
src/server/checker/runner/mem/expect.ts
Normal file
@@ -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",
|
||||
});
|
||||
}
|
||||
1
src/server/checker/runner/mem/index.ts
Normal file
1
src/server/checker/runner/mem/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MemChecker } from "./execute";
|
||||
72
src/server/checker/runner/mem/normalize.ts
Normal file
72
src/server/checker/runner/mem/normalize.ts
Normal file
@@ -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<string, unknown>;
|
||||
const converted: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
return {
|
||||
...target,
|
||||
expect: compactExpect(raw, normalizeAllFields(raw)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAllFields(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
55
src/server/checker/runner/mem/schema.ts
Normal file
55
src/server/checker/runner/mem/schema.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createAuthoringFieldSchema,
|
||||
createAuthoringValueExpectationSchema,
|
||||
createNormalizedValueExpectationSchema,
|
||||
sizeSchema,
|
||||
} from "../../schema/fragments";
|
||||
|
||||
export const memCheckerSchemas: CheckerSchemas = {
|
||||
authoring: {
|
||||
config: createMemConfigSchema("authoring"),
|
||||
expect: createMemExpectSchema("authoring"),
|
||||
},
|
||||
normalized: {
|
||||
config: createMemConfigSchema("normalized"),
|
||||
expect: createMemExpectSchema("normalized"),
|
||||
},
|
||||
};
|
||||
|
||||
function createMemConfigSchema(_kind: "authoring" | "normalized") {
|
||||
return Type.Object({}, { additionalProperties: false });
|
||||
}
|
||||
|
||||
function createMemExpectSchema(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 },
|
||||
);
|
||||
}
|
||||
75
src/server/checker/runner/mem/types.ts
Normal file
75
src/server/checker/runner/mem/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Systeminformation } from "systeminformation";
|
||||
|
||||
import type { RawValueExpectation, ValueExpectation } from "../../expect/types";
|
||||
import type { ResolvedTargetBase } from "../../types";
|
||||
|
||||
export type MemDataReader = () => Promise<Systeminformation.MemData>;
|
||||
|
||||
export interface MemStats {
|
||||
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 RawMemExpectConfig {
|
||||
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 ResolvedMemConfig {}
|
||||
|
||||
export interface ResolvedMemExpectConfig {
|
||||
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 ResolvedMemTarget extends ResolvedTargetBase {
|
||||
expect?: ResolvedMemExpectConfig;
|
||||
group: string;
|
||||
intervalMs: number;
|
||||
mem: ResolvedMemConfig;
|
||||
name: null | string;
|
||||
timeoutMs: number;
|
||||
type: "mem";
|
||||
}
|
||||
111
src/server/checker/runner/mem/validate.ts
Normal file
111
src/server/checker/runner/mem/validate.ts
Normal file
@@ -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 MEM_CONFIG_KEYS = new Set<string>([]);
|
||||
|
||||
const MEM_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 MEM_EXPECT_KEYS = new Set<string>(MEM_EXPECT_FIELDS);
|
||||
|
||||
export function validateMemConfig(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"] !== "mem") continue;
|
||||
issues.push(...validateMemTarget(target, `targets[${i}]`));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
if (isString(target["name"])) return target["name"];
|
||||
return isString(target["id"]) ? target["id"] : undefined;
|
||||
}
|
||||
|
||||
function validateMemExpect(target: Record<string, unknown>, 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 MEM_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 (!MEM_EXPECT_KEYS.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateMemTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
|
||||
const rawMem = target["mem"];
|
||||
if (!isPlainRecord(rawMem)) {
|
||||
issues.push(issue("required", joinPath(path, "mem"), "缺少 mem 配置分组", targetName));
|
||||
} else {
|
||||
for (const key of Object.keys(rawMem)) {
|
||||
if (!MEM_CONFIG_KEYS.has(key)) {
|
||||
issues.push(issue("unknown-field", joinPath(joinPath(path, "mem"), key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(...validateMemExpect(target, path));
|
||||
|
||||
return issues;
|
||||
}
|
||||
@@ -2270,6 +2270,82 @@ targets:
|
||||
);
|
||||
});
|
||||
|
||||
test("解析最简 cpu 配置", async () => {
|
||||
const configPath = join(tempDir, "minimal-cpu.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- id: "local-cpu"
|
||||
type: cpu
|
||||
cpu: {}
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]! as Record<string, unknown>;
|
||||
expect(t["type"]).toBe("cpu");
|
||||
expect((t["cpu"] as Record<string, unknown>)["sampleDurationMs"]).toBe(1000);
|
||||
expect((t["cpu"] as Record<string, unknown>)["includePerCore"]).toBe(false);
|
||||
expect(t["group"]).toBe("default");
|
||||
expect(t["intervalMs"]).toBe(30000);
|
||||
expect(t["timeoutMs"]).toBe(10000);
|
||||
});
|
||||
|
||||
test("解析 cpu expect 配置", async () => {
|
||||
const configPath = join(tempDir, "cpu-expect.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- id: "local-cpu"
|
||||
type: cpu
|
||||
cpu:
|
||||
sampleDuration: "2s"
|
||||
includePerCore: true
|
||||
expect:
|
||||
usagePercent: { lte: 85 }
|
||||
idlePercent: { gte: 15 }
|
||||
maxCoreUsagePercent: { lte: 95 }
|
||||
durationMs: { lte: 3000 }
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]! as Record<string, unknown>;
|
||||
expect((t["cpu"] as Record<string, unknown>)["sampleDurationMs"]).toBe(2000);
|
||||
expect((t["cpu"] as Record<string, unknown>)["includePerCore"]).toBe(true);
|
||||
expect((t["expect"] as Record<string, unknown>)["usagePercent"]).toEqual({ lte: 85 });
|
||||
});
|
||||
|
||||
test("cpu expect 未知字段抛出错误", async () => {
|
||||
await expectConfigError(
|
||||
"cpu-unknown-expect.yaml",
|
||||
`targets:
|
||||
- id: "local-cpu"
|
||||
type: cpu
|
||||
cpu: {}
|
||||
expect:
|
||||
logicalCoreCount: { gte: 4 }
|
||||
`,
|
||||
"expect.logicalCoreCount 是未知字段",
|
||||
);
|
||||
});
|
||||
|
||||
test("cpu sampleDuration >= timeout 抛出错误", async () => {
|
||||
await expectConfigError(
|
||||
"cpu-sample-too-long.yaml",
|
||||
`targets:
|
||||
- id: "local-cpu"
|
||||
type: cpu
|
||||
timeout: "1s"
|
||||
cpu:
|
||||
sampleDuration: "5s"
|
||||
`,
|
||||
"sampleDuration 必须小于 timeout",
|
||||
);
|
||||
});
|
||||
|
||||
describe("logging 配置", () => {
|
||||
test("logging 全部缺省时使用默认值", async () => {
|
||||
const configPath = join(tempDir, "logging-default.yaml");
|
||||
|
||||
177
tests/server/checker/runner/cpu/calculate.test.ts
Normal file
177
tests/server/checker/runner/cpu/calculate.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { CpuCoreSnapshot } from "../../../../../src/server/checker/runner/cpu/types";
|
||||
|
||||
import { calculateCpuStats, validateCpuSnapshots } from "../../../../../src/server/checker/runner/cpu/calculate";
|
||||
|
||||
function makeCore(user: number, nice: number, sys: number, idle: number, irq: number): CpuCoreSnapshot {
|
||||
return { times: { idle, irq, nice, sys, user } };
|
||||
}
|
||||
|
||||
describe("calculateCpuStats", () => {
|
||||
test("单核心完全空闲", () => {
|
||||
const before = [makeCore(0, 0, 0, 100, 0)];
|
||||
const after = [makeCore(0, 0, 0, 200, 0)];
|
||||
const stats = calculateCpuStats(before, after);
|
||||
expect(stats.usagePercent).toBe(0);
|
||||
expect(stats.idlePercent).toBe(100);
|
||||
expect(stats.maxCoreUsagePercent).toBe(0);
|
||||
expect(stats.minCoreUsagePercent).toBe(0);
|
||||
expect(stats.logicalCoreCount).toBe(1);
|
||||
});
|
||||
|
||||
test("单核心完全忙碌(idle 不变)", () => {
|
||||
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||
const after = [makeCore(200, 0, 0, 100, 0)];
|
||||
// idle delta = 0, total delta = 100
|
||||
// idlePercent = 0, usagePercent = 100
|
||||
const stats = calculateCpuStats(before, after);
|
||||
expect(stats.usagePercent).toBe(100);
|
||||
expect(stats.idlePercent).toBe(0);
|
||||
});
|
||||
|
||||
test("单核心部分使用", () => {
|
||||
const before = [makeCore(100, 0, 0, 900, 0)];
|
||||
const after = [makeCore(150, 0, 0, 950, 0)];
|
||||
// idle delta = 50, total delta = 100
|
||||
// idlePercent = 50, usagePercent = 50
|
||||
const stats = calculateCpuStats(before, after);
|
||||
expect(stats.usagePercent).toBe(50);
|
||||
expect(stats.idlePercent).toBe(50);
|
||||
});
|
||||
|
||||
test("多核心加权平均", () => {
|
||||
// 核心 0: idle delta = 200, total delta = 1000 -> 80% usage
|
||||
// 核心 1: idle delta = 800, total delta = 1000 -> 20% usage
|
||||
const before = [makeCore(0, 0, 0, 1000, 0), makeCore(0, 0, 0, 1000, 0)];
|
||||
const after = [makeCore(800, 0, 0, 1200, 0), makeCore(200, 0, 0, 1800, 0)];
|
||||
const stats = calculateCpuStats(before, after);
|
||||
// 总 idle = 200+800=1000, 总 delta = 1000+1000=2000
|
||||
// idlePercent = 1000/2000*100 = 50
|
||||
// usagePercent = 100 - 50 = 50
|
||||
expect(stats.idlePercent).toBe(50);
|
||||
expect(stats.usagePercent).toBe(50);
|
||||
expect(stats.maxCoreUsagePercent).toBe(80);
|
||||
expect(stats.minCoreUsagePercent).toBe(20);
|
||||
expect(stats.logicalCoreCount).toBe(2);
|
||||
expect(stats.perCoreUsagePercent).toEqual([80, 20]);
|
||||
});
|
||||
|
||||
test("四核心各不相同", () => {
|
||||
const bf = [
|
||||
makeCore(1000, 0, 0, 9000, 0), // core 0 baseline
|
||||
makeCore(1000, 0, 0, 9000, 0), // core 1
|
||||
makeCore(1000, 0, 0, 9000, 0), // core 2
|
||||
makeCore(1000, 0, 0, 9000, 0), // core 3
|
||||
];
|
||||
const af = [
|
||||
makeCore(1900, 0, 0, 9100, 0), // delta: user=900, idle=100, total=1000 -> 90% usage, 10% idle
|
||||
makeCore(1500, 0, 0, 9500, 0), // delta: user=500, idle=500, total=1000 -> 50% usage
|
||||
makeCore(1200, 0, 0, 9800, 0), // delta: user=200, idle=800, total=1000 -> 20% usage
|
||||
makeCore(1010, 0, 0, 9990, 0), // delta: user=10, idle=990, total=1000 -> 1% usage
|
||||
];
|
||||
const stats = calculateCpuStats(bf, af);
|
||||
// 总 idle = 100+500+800+990 = 2390, 总 delta = 4000
|
||||
// idlePercent = 2390/4000*100 = 59.75 -> 59.8
|
||||
expect(stats.idlePercent).toBe(59.8);
|
||||
expect(stats.usagePercent).toBe(40.2);
|
||||
expect(stats.maxCoreUsagePercent).toBe(90);
|
||||
expect(stats.minCoreUsagePercent).toBe(1);
|
||||
expect(stats.perCoreUsagePercent).toEqual([90, 50, 20, 1]);
|
||||
expect(stats.logicalCoreCount).toBe(4);
|
||||
});
|
||||
|
||||
test("delta 为 0 时返回 0", () => {
|
||||
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||
const after = [makeCore(100, 0, 0, 100, 0)];
|
||||
const stats = calculateCpuStats(before, after);
|
||||
expect(stats.usagePercent).toBe(0);
|
||||
expect(stats.idlePercent).toBe(0);
|
||||
});
|
||||
|
||||
test("保留 1 位小数", () => {
|
||||
// 总 idle = 333, 总 delta = 1000 -> idlePercent = 33.3
|
||||
const before = [makeCore(0, 0, 0, 1000, 0)];
|
||||
const after = [makeCore(667, 0, 0, 1333, 0)];
|
||||
const stats = calculateCpuStats(before, after);
|
||||
// idle delta = 333, total delta = 1000
|
||||
expect(stats.idlePercent).toBe(33.3);
|
||||
expect(stats.usagePercent).toBe(66.7);
|
||||
});
|
||||
|
||||
test("nice 和 irq 计入 total 但不影响 idle", () => {
|
||||
const bf = [makeCore(0, 0, 0, 0, 0)];
|
||||
const af = [makeCore(300, 100, 100, 400, 100)];
|
||||
// total delta = 300+100+100+400+100 = 1000
|
||||
// idle delta = 400
|
||||
// idlePercent = 400/1000*100 = 40
|
||||
const stats = calculateCpuStats(bf, af);
|
||||
expect(stats.idlePercent).toBe(40);
|
||||
expect(stats.usagePercent).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCpuSnapshots", () => {
|
||||
test("合法 snapshot 返回 null", () => {
|
||||
const before = [makeCore(100, 0, 0, 900, 0)];
|
||||
const after = [makeCore(200, 0, 0, 800, 0)];
|
||||
expect(validateCpuSnapshots(before, after)).toBeNull();
|
||||
});
|
||||
|
||||
test("空 before snapshot", () => {
|
||||
const after = [makeCore(0, 0, 0, 0, 0)];
|
||||
expect(validateCpuSnapshots([], after)).toBe("CPU 快照为空");
|
||||
});
|
||||
|
||||
test("空 after snapshot", () => {
|
||||
const before = [makeCore(0, 0, 0, 0, 0)];
|
||||
expect(validateCpuSnapshots(before, [])).toBe("CPU 快照为空");
|
||||
});
|
||||
|
||||
test("核心数不一致", () => {
|
||||
const before = [makeCore(0, 0, 0, 0, 0)];
|
||||
const after = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
|
||||
expect(validateCpuSnapshots(before, after)).toBe("CPU 快照核心数不一致: before=1, after=2");
|
||||
});
|
||||
|
||||
test("before 包含 NaN time 值", () => {
|
||||
const before = [{ times: { idle: NaN, irq: 0, nice: 0, sys: 0, user: 0 } }];
|
||||
const after = [makeCore(0, 0, 0, 0, 0)];
|
||||
const error = validateCpuSnapshots(before, after);
|
||||
expect(error).toContain("非有限值");
|
||||
expect(error).toContain("before[0]");
|
||||
});
|
||||
|
||||
test("after 包含 Infinity time 值", () => {
|
||||
const before = [makeCore(0, 0, 0, 0, 0)];
|
||||
const after = [{ times: { idle: Infinity, irq: 0, nice: 0, sys: 0, user: 0 } }];
|
||||
const error = validateCpuSnapshots(before, after);
|
||||
expect(error).toContain("非有限值");
|
||||
expect(error).toContain("after[0]");
|
||||
});
|
||||
|
||||
test("负数 total delta", () => {
|
||||
const before = [makeCore(1000, 0, 0, 0, 0)];
|
||||
const after = [makeCore(100, 0, 0, 0, 0)];
|
||||
const error = validateCpuSnapshots(before, after);
|
||||
expect(error).toContain("负数 delta");
|
||||
});
|
||||
|
||||
test("零 delta 合法", () => {
|
||||
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||
const after = [makeCore(100, 0, 0, 100, 0)];
|
||||
expect(validateCpuSnapshots(before, after)).toBeNull();
|
||||
});
|
||||
|
||||
test("零 delta 不产生除零错误", () => {
|
||||
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||
const after = [makeCore(100, 0, 0, 100, 0)];
|
||||
const stats = calculateCpuStats(before, after);
|
||||
expect(Number.isFinite(stats.usagePercent)).toBe(true);
|
||||
expect(Number.isFinite(stats.idlePercent)).toBe(true);
|
||||
expect(Number.isFinite(stats.maxCoreUsagePercent)).toBe(true);
|
||||
expect(Number.isFinite(stats.minCoreUsagePercent)).toBe(true);
|
||||
expect(stats.usagePercent).toBe(0);
|
||||
expect(stats.idlePercent).toBe(0);
|
||||
});
|
||||
});
|
||||
379
tests/server/checker/runner/cpu/execute.test.ts
Normal file
379
tests/server/checker/runner/cpu/execute.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { SnapshotReader } from "../../../../../src/server/checker/runner/cpu/execute";
|
||||
import type { CpuCoreSnapshot } from "../../../../../src/server/checker/runner/cpu/types";
|
||||
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||
|
||||
import { CpuChecker } from "../../../../../src/server/checker/runner/cpu/execute";
|
||||
|
||||
function makeCore(user: number, nice: number, sys: number, idle: number, irq: number): CpuCoreSnapshot {
|
||||
return { times: { idle, irq, nice, sys, user } };
|
||||
}
|
||||
|
||||
function makeResolveContext(
|
||||
overrides: Partial<{ configDir: string; defaultIntervalMs: number; defaultTimeoutMs: number }> = {},
|
||||
) {
|
||||
return {
|
||||
configDir: "/test",
|
||||
defaultIntervalMs: 30000,
|
||||
defaultTimeoutMs: 10000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CpuChecker resolve", () => {
|
||||
const checker = new CpuChecker();
|
||||
|
||||
test("默认值:sampleDurationMs=1000, includePerCore=false", () => {
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
expect(resolved.cpu.sampleDurationMs).toBe(1000);
|
||||
expect(resolved.cpu.includePerCore).toBe(false);
|
||||
});
|
||||
|
||||
test("显式配置覆盖默认值", () => {
|
||||
const target: RawTargetConfig = {
|
||||
cpu: { includePerCore: true, sampleDuration: "2s" },
|
||||
id: "cpu-test",
|
||||
type: "cpu",
|
||||
};
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
expect(resolved.cpu.sampleDurationMs).toBe(2000);
|
||||
expect(resolved.cpu.includePerCore).toBe(true);
|
||||
});
|
||||
|
||||
test("无 expect 时 expect 为 undefined", () => {
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
expect(resolved.expect).toBeUndefined();
|
||||
});
|
||||
|
||||
test("保留 expect 字段", () => {
|
||||
const target: RawTargetConfig = {
|
||||
cpu: {},
|
||||
expect: { usagePercent: { lte: 85 } },
|
||||
id: "cpu-test",
|
||||
type: "cpu",
|
||||
};
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
expect(resolved.expect).toEqual({ usagePercent: { lte: 85 } });
|
||||
});
|
||||
|
||||
test("type 为 cpu", () => {
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
expect(resolved.type).toBe("cpu");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CpuChecker execute", () => {
|
||||
function makeSnapshotReader(_first: CpuCoreSnapshot[], _second: CpuCoreSnapshot[]): SnapshotReader {
|
||||
let callCount = 0;
|
||||
const snapshots = [_first, _second];
|
||||
return () => {
|
||||
const result = snapshots[Math.min(callCount, snapshots.length - 1)]!;
|
||||
callCount++;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
test("成功匹配", async () => {
|
||||
// 50% usage, 50% idle
|
||||
const before = [makeCore(1000, 0, 0, 9000, 0)];
|
||||
const after = [makeCore(1500, 0, 0, 9500, 0)];
|
||||
const reader = makeSnapshotReader(before, after);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
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({
|
||||
idlePercent: 50,
|
||||
logicalCoreCount: 1,
|
||||
usagePercent: 50,
|
||||
});
|
||||
// 默认不包含 perCoreUsagePercent
|
||||
expect(result.observation!["perCoreUsagePercent"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("usagePercent mismatch", async () => {
|
||||
// 90% usage: before idle=0, after idle=1000, total=10000
|
||||
const before = [makeCore(0, 0, 0, 0, 0)];
|
||||
const after = [makeCore(9000, 0, 0, 1000, 0)];
|
||||
const reader = makeSnapshotReader(before, after);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
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("idlePercent mismatch", async () => {
|
||||
// idle = 10%: before idle=0, after idle=1000, total=10000
|
||||
const before = [makeCore(0, 0, 0, 0, 0)];
|
||||
const after = [makeCore(9000, 0, 0, 1000, 0)];
|
||||
const reader = makeSnapshotReader(before, after);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
resolved.expect = { idlePercent: { gte: 80 } };
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("idle");
|
||||
});
|
||||
|
||||
test("maxCoreUsagePercent mismatch", async () => {
|
||||
// core 0: 95% usage, core 1: 10% usage
|
||||
const bf = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
|
||||
const af = [makeCore(9500, 0, 0, 500, 0), makeCore(1000, 0, 0, 9000, 0)];
|
||||
const reader = makeSnapshotReader(bf, af);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
resolved.expect = { maxCoreUsagePercent: { lte: 80 } };
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("maxCoreUsage");
|
||||
});
|
||||
|
||||
test("minCoreUsagePercent mismatch", async () => {
|
||||
// core 0: 95% usage, core 1: 10% usage
|
||||
const bf = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
|
||||
const af = [makeCore(9500, 0, 0, 500, 0), makeCore(1000, 0, 0, 9000, 0)];
|
||||
const reader = makeSnapshotReader(bf, af);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
resolved.expect = { minCoreUsagePercent: { gte: 50 } };
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("minCoreUsage");
|
||||
});
|
||||
|
||||
test("durationMs mismatch", async () => {
|
||||
const before = [makeCore(0, 0, 0, 10000, 0)];
|
||||
const after = [makeCore(1000, 0, 0, 9000, 0)];
|
||||
const reader = makeSnapshotReader(before, after);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
resolved.expect = { durationMs: { lte: 0 } };
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("duration");
|
||||
});
|
||||
|
||||
test("超时取消", async () => {
|
||||
const before = [makeCore(0, 0, 0, 10000, 0)];
|
||||
const after = [makeCore(1000, 0, 0, 9000, 0)];
|
||||
const reader = makeSnapshotReader(before, after);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: { sampleDuration: "10s" }, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 100 }));
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const result = await checker.execute(resolved, { signal: controller.signal });
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("cpu");
|
||||
expect(result.failure?.path).toBe("timeout");
|
||||
});
|
||||
|
||||
test("第二次 snapshot 抛错返回 cpu/snapshot failure", async () => {
|
||||
const before = [makeCore(0, 0, 0, 10000, 0)];
|
||||
let callCount = 0;
|
||||
const reader: SnapshotReader = () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return before;
|
||||
throw new Error("second snapshot failed");
|
||||
};
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("cpu");
|
||||
expect(result.failure?.path).toBe("snapshot");
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("空 snapshot pair 返回 cpu/snapshot failure", async () => {
|
||||
const reader: SnapshotReader = () => [];
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("cpu");
|
||||
expect(result.failure?.path).toBe("snapshot");
|
||||
});
|
||||
|
||||
test("核心数不一致返回 cpu/snapshot failure", async () => {
|
||||
let callCount = 0;
|
||||
const snapshots = [[makeCore(0, 0, 0, 100, 0)], [makeCore(0, 0, 0, 100, 0), makeCore(0, 0, 0, 100, 0)]];
|
||||
const reader: SnapshotReader = () => {
|
||||
const result = snapshots[Math.min(callCount, snapshots.length - 1)]!;
|
||||
callCount++;
|
||||
return result;
|
||||
};
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("cpu");
|
||||
expect(result.failure?.path).toBe("snapshot");
|
||||
expect(result.failure?.message).toContain("核心数不一致");
|
||||
});
|
||||
|
||||
test("非有限 CPU time 值返回 cpu/snapshot failure", async () => {
|
||||
let callCount = 0;
|
||||
const snapshots: CpuCoreSnapshot[][] = [
|
||||
[makeCore(0, 0, 0, 100, 0)],
|
||||
[{ times: { idle: NaN, irq: 0, nice: 0, sys: 0, user: 100 } }],
|
||||
];
|
||||
const reader: SnapshotReader = () => {
|
||||
const result = snapshots[Math.min(callCount, snapshots.length - 1)]!;
|
||||
callCount++;
|
||||
return result;
|
||||
};
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("cpu");
|
||||
expect(result.failure?.path).toBe("snapshot");
|
||||
expect(result.failure?.message).toContain("非有限值");
|
||||
});
|
||||
|
||||
test("负数 CPU time delta 返回 cpu/snapshot failure", async () => {
|
||||
const before = [makeCore(1000, 0, 0, 0, 0)];
|
||||
const after = [makeCore(100, 0, 0, 0, 0)];
|
||||
const reader = makeSnapshotReader(before, after);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("cpu");
|
||||
expect(result.failure?.path).toBe("snapshot");
|
||||
expect(result.failure?.message).toContain("负数 delta");
|
||||
});
|
||||
|
||||
test("零 delta snapshot 返回稳定安全值", async () => {
|
||||
const before = [makeCore(100, 0, 0, 100, 0)];
|
||||
const after = [makeCore(100, 0, 0, 100, 0)];
|
||||
const reader = makeSnapshotReader(before, after);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
|
||||
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({
|
||||
idlePercent: 0,
|
||||
maxCoreUsagePercent: 0,
|
||||
minCoreUsagePercent: 0,
|
||||
usagePercent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("includePerCore=true 时输出 perCoreUsagePercent", async () => {
|
||||
const before = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)];
|
||||
const after = [makeCore(8000, 0, 0, 2000, 0), makeCore(2000, 0, 0, 8000, 0)];
|
||||
const reader = makeSnapshotReader(before, after);
|
||||
const checker = new CpuChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { cpu: { includePerCore: true }, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 }));
|
||||
|
||||
const ctx = { signal: new AbortController().signal };
|
||||
const result = await checker.execute(resolved, ctx);
|
||||
|
||||
expect(result.observation).toMatchObject({
|
||||
perCoreUsagePercent: [80, 20],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CpuChecker buildDetail", () => {
|
||||
test("正常输出格式", () => {
|
||||
const checker = new CpuChecker();
|
||||
const detail = checker.buildDetail({
|
||||
idlePercent: 40,
|
||||
logicalCoreCount: 8,
|
||||
maxCoreUsagePercent: 91.5,
|
||||
minCoreUsagePercent: 8.2,
|
||||
usagePercent: 60,
|
||||
});
|
||||
expect(detail).toBe("usage 60%, max core 91.5%, 8 cores");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CpuChecker serialize", () => {
|
||||
test("序列化输出", () => {
|
||||
const checker = new CpuChecker();
|
||||
const target: RawTargetConfig = { cpu: { sampleDuration: "1s" }, id: "cpu-test", type: "cpu" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
const result = checker.serialize(resolved);
|
||||
expect(result.target).toBe("cpu sample 1000ms");
|
||||
const config = JSON.parse(result.config) as { sampleDurationMs: number };
|
||||
expect(config.sampleDurationMs).toBe(1000);
|
||||
});
|
||||
});
|
||||
55
tests/server/checker/runner/cpu/expect.test.ts
Normal file
55
tests/server/checker/runner/cpu/expect.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
checkIdlePercent,
|
||||
checkMaxCoreUsage,
|
||||
checkMinCoreUsage,
|
||||
checkUsagePercent,
|
||||
} from "../../../../../src/server/checker/runner/cpu/expect";
|
||||
|
||||
describe("CPU 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("checkIdlePercent 匹配", () => {
|
||||
expect(checkIdlePercent(50, { gte: 15 }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("checkIdlePercent 不匹配", () => {
|
||||
const result = checkIdlePercent(10, { gte: 15 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("idle");
|
||||
});
|
||||
|
||||
test("checkMaxCoreUsage 匹配", () => {
|
||||
expect(checkMaxCoreUsage(80, { lte: 95 }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("checkMaxCoreUsage 不匹配", () => {
|
||||
const result = checkMaxCoreUsage(96, { lte: 95 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("maxCoreUsage");
|
||||
});
|
||||
|
||||
test("checkMinCoreUsage 匹配", () => {
|
||||
expect(checkMinCoreUsage(10, { gte: 5 }).matched).toBe(true);
|
||||
});
|
||||
|
||||
test("checkMinCoreUsage 不匹配", () => {
|
||||
const result = checkMinCoreUsage(3, { gte: 5 });
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("minCoreUsage");
|
||||
});
|
||||
|
||||
test("undefined matcher 直接通过", () => {
|
||||
expect(checkUsagePercent(99.9, undefined).matched).toBe(true);
|
||||
expect(checkIdlePercent(0, undefined).matched).toBe(true);
|
||||
});
|
||||
});
|
||||
41
tests/server/checker/runner/cpu/normalize.test.ts
Normal file
41
tests/server/checker/runner/cpu/normalize.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { normalizeTargetExpect } from "../../../../../src/server/checker/runner/cpu/normalize";
|
||||
|
||||
describe("normalizeTargetExpect (cpu)", () => {
|
||||
test("无 expect 直接返回", () => {
|
||||
const target = { cpu: {}, id: "test", type: "cpu" };
|
||||
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||
});
|
||||
|
||||
test("expect 为非对象直接返回", () => {
|
||||
const target = { cpu: {}, expect: "not-an-object", id: "test", type: "cpu" };
|
||||
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||
});
|
||||
|
||||
test("ValueMatcher 简写展开", () => {
|
||||
const target = { cpu: {}, expect: { usagePercent: 85 }, id: "test", type: "cpu" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ equals: 85 });
|
||||
});
|
||||
|
||||
test("已经是 matcher 对象的不变", () => {
|
||||
const target = { cpu: {}, expect: { usagePercent: { lte: 85 } }, id: "test", type: "cpu" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ lte: 85 });
|
||||
});
|
||||
|
||||
test("多个字段同时展开", () => {
|
||||
const target = {
|
||||
cpu: {},
|
||||
expect: { idlePercent: 15, maxCoreUsagePercent: { lte: 95 }, usagePercent: 85 },
|
||||
id: "test",
|
||||
type: "cpu",
|
||||
};
|
||||
const result = normalizeTargetExpect(target);
|
||||
const expectObj = result.expect as Record<string, unknown>;
|
||||
expect(expectObj["idlePercent"]).toEqual({ equals: 15 });
|
||||
expect(expectObj["maxCoreUsagePercent"]).toEqual({ lte: 95 });
|
||||
expect(expectObj["usagePercent"]).toEqual({ equals: 85 });
|
||||
});
|
||||
});
|
||||
77
tests/server/checker/runner/cpu/schema.test.ts
Normal file
77
tests/server/checker/runner/cpu/schema.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import Ajv from "ajv";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { cpuCheckerSchemas } from "../../../../../src/server/checker/runner/cpu/schema";
|
||||
|
||||
const ajv = new Ajv({ strict: false });
|
||||
|
||||
describe("CPU checker schema", () => {
|
||||
test("authoring config 允许变量引用", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.config);
|
||||
expect(validate({ includePerCore: "${per_core|false}", sampleDuration: "${sample_dur|1s}" })).toBe(true);
|
||||
});
|
||||
|
||||
test("normalized config 允许合法值", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.config);
|
||||
expect(validate({ includePerCore: true, sampleDuration: "1s" })).toBe(true);
|
||||
});
|
||||
|
||||
test("normalized config 空配置通过", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.config);
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
|
||||
test("config 拒绝额外字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.config);
|
||||
expect(validate({ extraField: true })).toBe(false);
|
||||
});
|
||||
|
||||
test("authoring expect 允许 ValueMatcher 简写", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||
expect(validate({ usagePercent: 85 })).toBe(true);
|
||||
expect(validate({ usagePercent: { lte: 85 } })).toBe(true);
|
||||
});
|
||||
|
||||
test("normalized expect 允许 matcher 对象", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||
expect(validate({ idlePercent: { gte: 15 }, usagePercent: { lte: 85 } })).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 拒绝 logicalCoreCount 字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||
expect(validate({ logicalCoreCount: { gte: 4 } })).toBe(false);
|
||||
});
|
||||
|
||||
test("expect 拒绝 userPercent 字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||
expect(validate({ userPercent: { lte: 50 } })).toBe(false);
|
||||
});
|
||||
|
||||
test("expect 拒绝 systemPercent 字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.authoring.expect);
|
||||
expect(validate({ systemPercent: { lte: 50 } })).toBe(false);
|
||||
});
|
||||
|
||||
test("expect 允许所有合法字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||
expect(
|
||||
validate({
|
||||
durationMs: { lte: 2000 },
|
||||
idlePercent: { gte: 15 },
|
||||
maxCoreUsagePercent: { lte: 95 },
|
||||
minCoreUsagePercent: { gte: 0 },
|
||||
usagePercent: { lte: 85 },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 拒绝额外字段", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||
expect(validate({ unknownField: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
test("expect 空对象通过", () => {
|
||||
const validate = ajv.compile(cpuCheckerSchemas.normalized.expect);
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
});
|
||||
84
tests/server/checker/runner/cpu/validate.test.ts
Normal file
84
tests/server/checker/runner/cpu/validate.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||
|
||||
import { validateCpuConfig } from "../../../../../src/server/checker/runner/cpu/validate";
|
||||
|
||||
function validate(target: RawTargetConfig) {
|
||||
return validateCpuConfig({ targets: [target] });
|
||||
}
|
||||
|
||||
describe("validateCpuConfig", () => {
|
||||
test("有效配置无错误", () => {
|
||||
expect(validate({ cpu: { sampleDuration: "1s" }, id: "cpu-test", type: "cpu" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("空 cpu 配置无错误", () => {
|
||||
expect(validate({ cpu: {}, id: "cpu-test", type: "cpu" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("缺少 cpu 配置分组", () => {
|
||||
const issues = validate({ id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("cpu") && i.code === "required")).toBe(true);
|
||||
});
|
||||
|
||||
test("无效 sampleDuration 格式", () => {
|
||||
const issues = validate({ cpu: { sampleDuration: "abc" }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("sampleDuration"))).toBe(true);
|
||||
});
|
||||
|
||||
test("sampleDuration >= timeout 报错", () => {
|
||||
const issues = validate({ cpu: { sampleDuration: "5s" }, id: "cpu-test", timeout: "5s", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("sampleDuration") && i.message.includes("必须小于 timeout"))).toBe(true);
|
||||
});
|
||||
|
||||
test("sampleDuration 大于默认 timeout (10s) 报错", () => {
|
||||
const issues = validate({ cpu: { sampleDuration: "15s" }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.message.includes("默认 10s"))).toBe(true);
|
||||
});
|
||||
|
||||
test("sampleDuration < timeout 通过", () => {
|
||||
const issues = validate({ cpu: { sampleDuration: "1s" }, id: "cpu-test", timeout: "5s", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.includes("sampleDuration"))).toBe(false);
|
||||
});
|
||||
|
||||
test("includePerCore 非布尔值报错", () => {
|
||||
const issues = validate({ cpu: { includePerCore: "yes" }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("includePerCore") && i.code === "invalid-type")).toBe(true);
|
||||
});
|
||||
|
||||
test("cpu 未知字段报错", () => {
|
||||
const issues = validate({ cpu: { extra: true }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("extra") && i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 未知字段报错", () => {
|
||||
const issues = validate({ cpu: {}, expect: { logicalCoreCount: { gte: 4 } }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("logicalCoreCount") && i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("expect userPercent 未知字段报错", () => {
|
||||
const issues = validate({ cpu: {}, expect: { userPercent: { lte: 50 } }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("userPercent") && i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("expect systemPercent 未知字段报错", () => {
|
||||
const issues = validate({ cpu: {}, expect: { systemPercent: { lte: 50 } }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.endsWith("systemPercent") && i.code === "unknown-field")).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 合法 ValueMatcher 通过", () => {
|
||||
const issues = validate({
|
||||
cpu: {},
|
||||
expect: { maxCoreUsagePercent: { lte: 95 }, usagePercent: { lte: 85 } },
|
||||
id: "cpu-test",
|
||||
type: "cpu",
|
||||
});
|
||||
expect(issues.filter((i) => i.path.includes("expect"))).toEqual([]);
|
||||
});
|
||||
|
||||
test("expect 非法 ValueMatcher 报错", () => {
|
||||
const issues = validate({ cpu: {}, expect: { usagePercent: [1, 2] }, id: "cpu-test", type: "cpu" });
|
||||
expect(issues.some((i) => i.path.includes("usagePercent"))).toBe(true);
|
||||
});
|
||||
});
|
||||
139
tests/server/checker/runner/mem/calculate.test.ts
Normal file
139
tests/server/checker/runner/mem/calculate.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Systeminformation } from "systeminformation";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { calculateMemStats } from "../../../../../src/server/checker/runner/mem/calculate";
|
||||
|
||||
function makeMemData(overrides: Partial<Systeminformation.MemData> = {}): 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("calculateMemStats", () => {
|
||||
test("usagePercent = activeBytes / totalBytes * 100", () => {
|
||||
const stats = calculateMemStats(makeMemData({ active: 4294967296, total: 8589934592 }));
|
||||
expect(stats.usagePercent).toBe(50);
|
||||
});
|
||||
|
||||
test("usedPercent = usedBytes / totalBytes * 100", () => {
|
||||
const stats = calculateMemStats(makeMemData({ total: 8589934592, used: 6442450944 }));
|
||||
expect(stats.usedPercent).toBe(75);
|
||||
});
|
||||
|
||||
test("freePercent = freeBytes / totalBytes * 100", () => {
|
||||
const stats = calculateMemStats(makeMemData({ free: 2147483648, total: 8589934592 }));
|
||||
expect(stats.freePercent).toBe(25);
|
||||
});
|
||||
|
||||
test("activePercent = activeBytes / totalBytes * 100", () => {
|
||||
const stats = calculateMemStats(makeMemData({ active: 3221225472, total: 8589934592 }));
|
||||
expect(stats.activePercent).toBe(37.5);
|
||||
});
|
||||
|
||||
test("availablePercent = availableBytes / totalBytes * 100", () => {
|
||||
const stats = calculateMemStats(makeMemData({ available: 6442450944, total: 8589934592 }));
|
||||
expect(stats.availablePercent).toBe(75);
|
||||
});
|
||||
|
||||
test("保留 1 位小数", () => {
|
||||
const stats = calculateMemStats(makeMemData({ active: 3000000000, total: 8000000000 }));
|
||||
expect(stats.usagePercent).toBe(37.5);
|
||||
});
|
||||
|
||||
test("round1 处理需要四舍五入的情况", () => {
|
||||
const stats = calculateMemStats(makeMemData({ active: 3333333333, total: 10000000000 }));
|
||||
expect(stats.usagePercent).toBe(33.3);
|
||||
});
|
||||
|
||||
test("total 为 0 时百分比为 0", () => {
|
||||
const stats = calculateMemStats(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 = calculateMemStats(makeMemData({ buffcache: 0 }));
|
||||
expect(stats.buffcacheBytes).toBe(0);
|
||||
});
|
||||
|
||||
test("buffcacheBytes 为正数时保留", () => {
|
||||
const stats = calculateMemStats(makeMemData({ buffcache: 1073741824 }));
|
||||
expect(stats.buffcacheBytes).toBe(1073741824);
|
||||
});
|
||||
|
||||
test("所有字节字段正确映射", () => {
|
||||
const data = makeMemData({
|
||||
active: 1000,
|
||||
available: 2000,
|
||||
free: 3000,
|
||||
total: 4000,
|
||||
used: 3500,
|
||||
});
|
||||
const stats = calculateMemStats(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("calculateMemStats swap", () => {
|
||||
test("swap 不可用:swaptotal=0 时 swapUsagePercent=null", () => {
|
||||
const stats = calculateMemStats(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 = calculateMemStats(makeMemData({ swaptotal: 0 }));
|
||||
expect(stats.swapUsagePercent).toBe(null);
|
||||
});
|
||||
|
||||
test("swap 已使用", () => {
|
||||
const stats = calculateMemStats(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 = calculateMemStats(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 = calculateMemStats(
|
||||
makeMemData({ swapfree: 3000000000, swaptotal: 10000000000, swapused: 7000000000 }),
|
||||
);
|
||||
expect(stats.swapUsagePercent).toBe(70);
|
||||
});
|
||||
|
||||
test("swap 合法 0 不被转换为 null", () => {
|
||||
const stats = calculateMemStats(makeMemData({ swapfree: 4294967296, swaptotal: 4294967296, swapused: 0 }));
|
||||
expect(stats.swapUsedBytes).toBe(0);
|
||||
expect(stats.swapUsagePercent).toBe(0);
|
||||
});
|
||||
});
|
||||
244
tests/server/checker/runner/mem/execute.test.ts
Normal file
244
tests/server/checker/runner/mem/execute.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Systeminformation } from "systeminformation";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||
|
||||
import { MemChecker } from "../../../../../src/server/checker/runner/mem/execute";
|
||||
|
||||
function makeMemData(overrides: Partial<Systeminformation.MemData> = {}): 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("MemChecker resolve", () => {
|
||||
const checker = new MemChecker();
|
||||
|
||||
test("默认值:mem 为空对象", () => {
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
expect(resolved.mem).toEqual({});
|
||||
});
|
||||
|
||||
test("无 expect 时 expect 为 undefined", () => {
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
expect(resolved.expect).toBeUndefined();
|
||||
});
|
||||
|
||||
test("保留 expect 字段", () => {
|
||||
const target: RawTargetConfig = {
|
||||
expect: { usagePercent: { lte: 85 } },
|
||||
id: "mem-test",
|
||||
mem: {},
|
||||
type: "mem",
|
||||
};
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
expect(resolved.expect).toEqual({ usagePercent: { lte: 85 } });
|
||||
});
|
||||
|
||||
test("type 为 mem", () => {
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
expect(resolved.type).toBe("mem");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemChecker execute", () => {
|
||||
test("成功匹配", async () => {
|
||||
const data = makeMemData({ active: 4294967296, total: 8589934592 });
|
||||
const reader = () => Promise.resolve(data);
|
||||
const checker = new MemChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
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 MemChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
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 MemChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
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 MemChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
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("mem");
|
||||
expect(result.failure?.path).toBe("snapshot");
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("signal 已 abort 时返回 timeout failure", async () => {
|
||||
const reader = () => Promise.resolve(makeMemData());
|
||||
const checker = new MemChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await checker.execute(resolved, { signal: controller.signal });
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("mem");
|
||||
expect(result.failure?.path).toBe("timeout");
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("pending reader 被 signal abort 后返回 timeout failure", async () => {
|
||||
const reader = () =>
|
||||
new Promise<Systeminformation.MemData>(() => {
|
||||
// 故意永不 resolve,模拟悬挂的 reader
|
||||
});
|
||||
const checker = new MemChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
|
||||
const controller = new AbortController();
|
||||
const executePromise = checker.execute(resolved, { signal: controller.signal });
|
||||
|
||||
controller.abort();
|
||||
|
||||
const result = await executePromise;
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("mem");
|
||||
expect(result.failure?.path).toBe("timeout");
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("reader 在 abort 前 resolve 时返回正常结果", async () => {
|
||||
const data = makeMemData({ active: 4294967296, total: 8589934592 });
|
||||
const reader = () => Promise.resolve(data);
|
||||
const checker = new MemChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
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("detail 格式", async () => {
|
||||
const data = makeMemData({ active: 4294967296, total: 8589934592 });
|
||||
const reader = () => Promise.resolve(data);
|
||||
const checker = new MemChecker(reader);
|
||||
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
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("MemChecker serialize", () => {
|
||||
test("序列化输出", () => {
|
||||
const checker = new MemChecker();
|
||||
const target: RawTargetConfig = { id: "mem-test", mem: {}, type: "mem" };
|
||||
const resolved = checker.resolve(target, makeResolveContext());
|
||||
const result = checker.serialize(resolved);
|
||||
expect(result.target).toBe("mem");
|
||||
const config = JSON.parse(result.config) as Record<string, unknown>;
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
});
|
||||
149
tests/server/checker/runner/mem/expect.test.ts
Normal file
149
tests/server/checker/runner/mem/expect.test.ts
Normal file
@@ -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/mem/expect";
|
||||
|
||||
describe("Mem 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("Mem 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("Mem 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("Mem 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");
|
||||
});
|
||||
});
|
||||
72
tests/server/checker/runner/mem/normalize.test.ts
Normal file
72
tests/server/checker/runner/mem/normalize.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { normalizeTargetExpect } from "../../../../../src/server/checker/runner/mem/normalize";
|
||||
|
||||
describe("normalizeTargetExpect (mem)", () => {
|
||||
test("无 expect 直接返回", () => {
|
||||
const target = { id: "test", mem: {}, type: "mem" };
|
||||
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||
});
|
||||
|
||||
test("expect 为非对象直接返回", () => {
|
||||
const target = { expect: "not-an-object", id: "test", mem: {}, type: "mem" };
|
||||
expect(normalizeTargetExpect(target)).toEqual(target);
|
||||
});
|
||||
|
||||
test("字节大小字符串 512MB 转换为数字", () => {
|
||||
const target = { expect: { usedBytes: "512MB" }, id: "test", mem: {}, type: "mem" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["usedBytes"]).toEqual({ equals: 536870912 });
|
||||
});
|
||||
|
||||
test("字节大小字符串 1GB 转换为数字", () => {
|
||||
const target = { expect: { totalBytes: "1GB" }, id: "test", mem: {}, type: "mem" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["totalBytes"]).toEqual({ equals: 1073741824 });
|
||||
});
|
||||
|
||||
test("数字字节 matcher 保持不变", () => {
|
||||
const target = { expect: { usedBytes: 1073741824 }, id: "test", mem: {}, type: "mem" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["usedBytes"]).toEqual({ equals: 1073741824 });
|
||||
});
|
||||
|
||||
test("百分比 matcher 正常展开", () => {
|
||||
const target = { expect: { usagePercent: 85 }, id: "test", mem: {}, type: "mem" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ equals: 85 });
|
||||
});
|
||||
|
||||
test("matcher 对象保持不变", () => {
|
||||
const target = { expect: { usagePercent: { lte: 85 } }, id: "test", mem: {}, type: "mem" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["usagePercent"]).toEqual({ lte: 85 });
|
||||
});
|
||||
|
||||
test("字节 matcher 对象内字符串转换", () => {
|
||||
const target = { expect: { usedBytes: { gte: "512MB" } }, id: "test", mem: {}, type: "mem" };
|
||||
const result = normalizeTargetExpect(target);
|
||||
expect((result.expect as Record<string, unknown>)["usedBytes"]).toEqual({ gte: 536870912 });
|
||||
});
|
||||
|
||||
test("多个字段同时处理", () => {
|
||||
const target = {
|
||||
expect: { freePercent: 25, totalBytes: "16GB", usagePercent: { lte: 85 } },
|
||||
id: "test",
|
||||
mem: {},
|
||||
type: "mem",
|
||||
};
|
||||
const result = normalizeTargetExpect(target);
|
||||
const expectObj = result.expect as Record<string, unknown>;
|
||||
expect(expectObj["freePercent"]).toEqual({ equals: 25 });
|
||||
expect(expectObj["totalBytes"]).toEqual({ equals: 17179869184 });
|
||||
expect(expectObj["usagePercent"]).toEqual({ lte: 85 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTargetExpect (mem) 错误", () => {
|
||||
test("非法大小字符串抛出", () => {
|
||||
const target = { expect: { usedBytes: "abc" }, id: "test", mem: {}, type: "mem" };
|
||||
expect(() => normalizeTargetExpect(target)).toThrow();
|
||||
});
|
||||
});
|
||||
95
tests/server/checker/runner/mem/schema.test.ts
Normal file
95
tests/server/checker/runner/mem/schema.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import Ajv from "ajv";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { memCheckerSchemas } from "../../../../../src/server/checker/runner/mem/schema";
|
||||
|
||||
const ajv = new Ajv({ strict: false });
|
||||
|
||||
describe("Mem checker schema", () => {
|
||||
test("authoring config 空配置通过", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.authoring.config);
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
|
||||
test("normalized config 空配置通过", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.normalized.config);
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
|
||||
test("config 拒绝额外字段", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.authoring.config);
|
||||
expect(validate({ extraField: true })).toBe(false);
|
||||
});
|
||||
|
||||
test("authoring expect 允许百分比 ValueMatcher 简写", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.authoring.expect);
|
||||
expect(validate({ usagePercent: 85 })).toBe(true);
|
||||
expect(validate({ usagePercent: { lte: 85 } })).toBe(true);
|
||||
});
|
||||
|
||||
test("authoring expect 允许字节字段字符串", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.authoring.expect);
|
||||
expect(validate({ usedBytes: "512MB" })).toBe(true);
|
||||
expect(validate({ totalBytes: "1GB" })).toBe(true);
|
||||
});
|
||||
|
||||
test("authoring expect 允许字节字段数字", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.authoring.expect);
|
||||
expect(validate({ usedBytes: 536870912 })).toBe(true);
|
||||
});
|
||||
|
||||
test("normalized expect 允许 matcher 对象", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.normalized.expect);
|
||||
expect(validate({ freePercent: { gte: 15 }, usagePercent: { lte: 85 } })).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 拒绝未知字段", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.authoring.expect);
|
||||
expect(validate({ unknownField: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
test("expect 空对象通过", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.normalized.expect);
|
||||
expect(validate({})).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 允许所有合法百分比字段", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.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(memCheckerSchemas.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(memCheckerSchemas.normalized.expect);
|
||||
expect(validate({ durationMs: { lte: 5000 } })).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 允许 buffcacheBytes 字段", () => {
|
||||
const validate = ajv.compile(memCheckerSchemas.normalized.expect);
|
||||
expect(validate({ buffcacheBytes: { lte: 2147483648 } })).toBe(true);
|
||||
});
|
||||
});
|
||||
87
tests/server/checker/runner/mem/validate.test.ts
Normal file
87
tests/server/checker/runner/mem/validate.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { RawTargetConfig } from "../../../../../src/server/checker/types";
|
||||
|
||||
import { validateMemConfig } from "../../../../../src/server/checker/runner/mem/validate";
|
||||
|
||||
function validate(target: RawTargetConfig) {
|
||||
return validateMemConfig({ targets: [target] });
|
||||
}
|
||||
|
||||
describe("validateMemConfig", () => {
|
||||
test("有效配置无错误", () => {
|
||||
expect(validate({ id: "mem-test", mem: {}, type: "mem" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("缺少 mem 配置分组", () => {
|
||||
const issues = validate({ id: "mem-test", type: "mem" });
|
||||
expect(issues.some((i) => i.path.endsWith("mem") && i.code === "required")).toBe(true);
|
||||
});
|
||||
|
||||
test("mem 未知字段报错", () => {
|
||||
const issues = validate({ id: "mem-test", mem: { extra: true }, type: "mem" });
|
||||
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", mem: {}, type: "mem" });
|
||||
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",
|
||||
mem: {},
|
||||
type: "mem",
|
||||
});
|
||||
expect(issues.filter((i) => i.path.includes("expect"))).toEqual([]);
|
||||
});
|
||||
|
||||
test("expect 非法 ValueMatcher 报错", () => {
|
||||
const issues = validate({ expect: { usagePercent: [1, 2] }, id: "mem-test", mem: {}, type: "mem" });
|
||||
expect(issues.some((i) => i.path.includes("usagePercent"))).toBe(true);
|
||||
});
|
||||
|
||||
test("expect 合法字节大小字符串通过", () => {
|
||||
const issues = validate({ expect: { usedBytes: "512MB" }, id: "mem-test", mem: {}, type: "mem" });
|
||||
expect(issues.filter((i) => i.path.includes("usedBytes"))).toEqual([]);
|
||||
});
|
||||
|
||||
test("expect 非法字节大小字符串报错", () => {
|
||||
const issues = validate({ expect: { usedBytes: "abc" }, id: "mem-test", mem: {}, type: "mem" });
|
||||
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",
|
||||
mem: {},
|
||||
type: "mem",
|
||||
});
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
test("非 mem type 的 target 不校验", () => {
|
||||
const issues = validate({ id: "other-test", type: "http" });
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -73,8 +73,33 @@ describe("CheckerRegistry", () => {
|
||||
const second = createDefaultCheckerRegistry();
|
||||
first.register(createChecker("custom"));
|
||||
|
||||
expect(first.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws", "custom"]);
|
||||
expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws"]);
|
||||
expect(first.supportedTypes).toEqual([
|
||||
"http",
|
||||
"cmd",
|
||||
"db",
|
||||
"tcp",
|
||||
"icmp",
|
||||
"udp",
|
||||
"llm",
|
||||
"dns",
|
||||
"ws",
|
||||
"cpu",
|
||||
"mem",
|
||||
"custom",
|
||||
]);
|
||||
expect(second.supportedTypes).toEqual([
|
||||
"http",
|
||||
"cmd",
|
||||
"db",
|
||||
"tcp",
|
||||
"icmp",
|
||||
"udp",
|
||||
"llm",
|
||||
"dns",
|
||||
"ws",
|
||||
"cpu",
|
||||
"mem",
|
||||
]);
|
||||
expect(
|
||||
first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect),
|
||||
).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user