From c2dcfab80cede980279eadf799d86399ad6a587a Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 26 May 2026 22:34:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=9C=AC=E6=9C=BA=20?= =?UTF-8?q?CPU=20checker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 type: cpu checker,基于 os.cpus() 两次快照计算 CPU 使用率 - 配置项:sampleDuration(默认 1s)、includePerCore(默认 false) - expect 字段:usagePercent、idlePercent、maxCoreUsagePercent、minCoreUsagePercent、durationMs - idlePercent 与 usagePercent 互补恒等于 100,百分比范围 0-100 - logicalCoreCount 仅输出到 observation,不作为 expect 字段 - 不暴露 userPercent / systemPercent - 语义校验禁止 sampleDuration >= timeout - 支持 AbortSignal 超时取消 - 完整测试覆盖:schema、validate、normalize、resolve、calculate、execute、expect、config-loader - 新增用户文档 docs/user/checkers/cpu.md - 更新 checker 索引、配置类型列表、示例配置和 schema --- docs/user/checkers/README.md | 2 + docs/user/checkers/cpu.md | 74 +++ docs/user/configuration.md | 2 +- probe-config.schema.json | 477 ++++++++++++++++++ probes.example.yaml | 14 + src/server/checker/runner/cpu/calculate.ts | 72 +++ src/server/checker/runner/cpu/execute.ts | 186 +++++++ src/server/checker/runner/cpu/expect.ts | 35 ++ src/server/checker/runner/cpu/index.ts | 1 + src/server/checker/runner/cpu/normalize.ts | 20 + src/server/checker/runner/cpu/schema.ts | 46 ++ src/server/checker/runner/cpu/types.ts | 59 +++ src/server/checker/runner/cpu/validate.ts | 136 +++++ src/server/checker/runner/index.ts | 2 + tests/server/checker/config-loader.test.ts | 76 +++ .../checker/runner/cpu/calculate.test.ts | 112 ++++ .../server/checker/runner/cpu/execute.test.ts | 255 ++++++++++ .../server/checker/runner/cpu/expect.test.ts | 55 ++ .../checker/runner/cpu/normalize.test.ts | 41 ++ .../server/checker/runner/cpu/schema.test.ts | 77 +++ .../checker/runner/cpu/validate.test.ts | 84 +++ tests/server/checker/runner/registry.test.ts | 16 +- 22 files changed, 1839 insertions(+), 3 deletions(-) create mode 100644 docs/user/checkers/cpu.md create mode 100644 src/server/checker/runner/cpu/calculate.ts create mode 100644 src/server/checker/runner/cpu/execute.ts create mode 100644 src/server/checker/runner/cpu/expect.ts create mode 100644 src/server/checker/runner/cpu/index.ts create mode 100644 src/server/checker/runner/cpu/normalize.ts create mode 100644 src/server/checker/runner/cpu/schema.ts create mode 100644 src/server/checker/runner/cpu/types.ts create mode 100644 src/server/checker/runner/cpu/validate.ts create mode 100644 tests/server/checker/runner/cpu/calculate.test.ts create mode 100644 tests/server/checker/runner/cpu/execute.test.ts create mode 100644 tests/server/checker/runner/cpu/expect.test.ts create mode 100644 tests/server/checker/runner/cpu/normalize.test.ts create mode 100644 tests/server/checker/runner/cpu/schema.test.ts create mode 100644 tests/server/checker/runner/cpu/validate.test.ts diff --git a/docs/user/checkers/README.md b/docs/user/checkers/README.md index 719f1ac..5235883 100644 --- a/docs/user/checkers/README.md +++ b/docs/user/checkers/README.md @@ -17,6 +17,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一 | `dns` | 本机解析或指定 DNS server 协议级检查 | [DNS](dns.md) | | `llm` | 大模型服务应用层健康检查 | [LLM](llm.md) | | `ws` | WebSocket 可达性和消息交互检查 | [WS](ws.md) | +| `cpu` | 本机 CPU 使用率健康检查 | [CPU](cpu.md) | ## 选择建议 @@ -31,6 +32,7 @@ Checker 是 DiAL 的拨测执行单元。每个 target 通过 `type` 选择一 | 域名解析值、DNS RCODE、TTL、flags | `dns` | | LLM API 是否可用、输出是否符合预期 | `llm` | | WebSocket 可达性或消息交互验证 | `ws` | +| 本机 CPU 使用率健康检查 | `cpu` | ## 通用字段 diff --git a/docs/user/checkers/cpu.md b/docs/user/checkers/cpu.md new file mode 100644 index 0000000..215f452 --- /dev/null +++ b/docs/user/checkers/cpu.md @@ -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` 互补(`idlePercent = 100 - usagePercent`) | 否 | 无 | +| `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 字段、行为或语义时,必须更新本文档。 diff --git a/docs/user/configuration.md b/docs/user/configuration.md index ff8ef4f..595ff80 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -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` | diff --git a/probe-config.schema.json b/probe-config.schema.json index 23fcb7a..08dae17 100644 --- a/probe-config.schema.json +++ b/probe-config.schema.json @@ -6373,6 +6373,483 @@ } } } + }, + { + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "type", + "cpu" + ], + "properties": { + "description": { + "anyOf": [ + { + "type": "null" + }, + { + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + } + ] + }, + "expect": { + "additionalProperties": false, + "type": "object", + "properties": { + "durationMs": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "idlePercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "maxCoreUsagePercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "minCoreUsagePercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + }, + "usagePercent": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + { + "additionalProperties": false, + "minProperties": 1, + "type": "object", + "properties": { + "contains": { + "type": "string" + }, + "empty": { + "type": "boolean" + }, + "equals": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": {}, + "type": "array" + }, + { + "additionalProperties": {}, + "type": "object" + } + ] + }, + "exists": { + "type": "boolean" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "regex": { + "type": "string" + } + } + } + ] + } + } + }, + "group": { + "type": "string" + }, + "id": { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + "interval": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "null" + }, + { + "anyOf": [ + { + "maxLength": 30, + "minLength": 1, + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + } + ] + }, + "timeout": { + "type": "string" + }, + "type": { + "const": "cpu", + "type": "string" + }, + "cpu": { + "additionalProperties": false, + "type": "object", + "properties": { + "includePerCore": { + "anyOf": [ + { + "type": "boolean" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "sampleDuration": { + "anyOf": [ + { + "type": "string" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + } + } + } + } } ] } diff --git a/probes.example.yaml b/probes.example.yaml index 88f3d2d..85a62e4 100644 --- a/probes.example.yaml +++ b/probes.example.yaml @@ -353,3 +353,17 @@ 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 diff --git a/src/server/checker/runner/cpu/calculate.ts b/src/server/checker/runner/cpu/calculate.ts new file mode 100644 index 0000000..15965cc --- /dev/null +++ b/src/server/checker/runner/cpu/calculate.ts @@ -0,0 +1,72 @@ +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, + }, + })); +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} diff --git a/src/server/checker/runner/cpu/execute.ts b/src/server/checker/runner/cpu/execute.ts new file mode 100644 index 0000000..18e5a9a --- /dev/null +++ b/src/server/checker/runner/cpu/execute.ts @@ -0,0 +1,186 @@ +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 } 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 { + readonly configKey = "cpu"; + + readonly schemas = cpuCheckerSchemas; + + readonly type = "cpu"; + + constructor(private readonly readSnapshot: SnapshotReader = readCpuSnapshot) {} + + buildDetail(observation: Record): 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 { + 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); + + const after = aborted ? null : this.readSnapshot(); + const durationMs = Math.round(performance.now() - start); + + if (aborted || after === null) { + return { + detail: null, + durationMs, + failure: errorFailure("cpu", "timeout", `CPU 采样超时 (${t.timeoutMs}ms)`), + matched: false, + observation: null, + targetId: t.id, + timestamp, + }; + } + + const stats = calculateCpuStats(before, after); + const result = checkStats(stats, t.expect, durationMs); + + const observation: Record = { + 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 { + if (signal.aborted) return true; + + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(false); + }, ms); + + function onAbort() { + clearTimeout(timer); + resolve(true); + } + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/src/server/checker/runner/cpu/expect.ts b/src/server/checker/runner/cpu/expect.ts new file mode 100644 index 0000000..bd00433 --- /dev/null +++ b/src/server/checker/runner/cpu/expect.ts @@ -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", + }); +} diff --git a/src/server/checker/runner/cpu/index.ts b/src/server/checker/runner/cpu/index.ts new file mode 100644 index 0000000..cbe2361 --- /dev/null +++ b/src/server/checker/runner/cpu/index.ts @@ -0,0 +1 @@ +export { CpuChecker } from "./execute"; diff --git a/src/server/checker/runner/cpu/normalize.ts b/src/server/checker/runner/cpu/normalize.ts new file mode 100644 index 0000000..4d8ef4a --- /dev/null +++ b/src/server/checker/runner/cpu/normalize.ts @@ -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; + 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"]), + }), + }; +} diff --git a/src/server/checker/runner/cpu/schema.ts b/src/server/checker/runner/cpu/schema.ts new file mode 100644 index 0000000..71f27fd --- /dev/null +++ b/src/server/checker/runner/cpu/schema.ts @@ -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 }, + ); +} diff --git a/src/server/checker/runner/cpu/types.ts b/src/server/checker/runner/cpu/types.ts new file mode 100644 index 0000000..0ea8d11 --- /dev/null +++ b/src/server/checker/runner/cpu/types.ts @@ -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"; +} diff --git a/src/server/checker/runner/cpu/validate.ts b/src/server/checker/runner/cpu/validate.ts new file mode 100644 index 0000000..5eaad79 --- /dev/null +++ b/src/server/checker/runner/cpu/validate.ts @@ -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 | undefined { + if (isString(target["name"])) return target["name"]; + return isString(target["id"]) ? target["id"] : undefined; +} + +function validateCpuExpect(target: Record, path: string): ConfigValidationIssue[] { + const rawExpect = target["expect"]; + if (rawExpect === undefined || rawExpect === null || !isPlainRecord(rawExpect)) return []; + const expect = rawExpect; + const issues: ConfigValidationIssue[] = []; + const targetName = getTargetName(target); + const expectPath = joinPath(path, "expect"); + + 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, 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; +} diff --git a/src/server/checker/runner/index.ts b/src/server/checker/runner/index.ts index c0b8a77..8ad510c 100644 --- a/src/server/checker/runner/index.ts +++ b/src/server/checker/runner/index.ts @@ -1,4 +1,5 @@ import { CommandChecker } from "./cmd"; +import { CpuChecker } from "./cpu"; import { DbChecker } from "./db"; import { DnsChecker } from "./dns"; import { HttpChecker } from "./http"; @@ -19,6 +20,7 @@ const checkers = [ new LlmChecker(), new DnsChecker(), new WsChecker(), + new CpuChecker(), ]; export function createDefaultCheckerRegistry(): CheckerRegistry { diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index a7e06b8..c542098 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -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; + expect(t["type"]).toBe("cpu"); + expect((t["cpu"] as Record)["sampleDurationMs"]).toBe(1000); + expect((t["cpu"] as Record)["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; + expect((t["cpu"] as Record)["sampleDurationMs"]).toBe(2000); + expect((t["cpu"] as Record)["includePerCore"]).toBe(true); + expect((t["expect"] as Record)["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"); diff --git a/tests/server/checker/runner/cpu/calculate.test.ts b/tests/server/checker/runner/cpu/calculate.test.ts new file mode 100644 index 0000000..1c6c0bf --- /dev/null +++ b/tests/server/checker/runner/cpu/calculate.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; + +import type { CpuCoreSnapshot } from "../../../../../src/server/checker/runner/cpu/types"; + +import { calculateCpuStats } 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); + }); +}); diff --git a/tests/server/checker/runner/cpu/execute.test.ts b/tests/server/checker/runner/cpu/execute.test.ts new file mode 100644 index 0000000..c323a72 --- /dev/null +++ b/tests/server/checker/runner/cpu/execute.test.ts @@ -0,0 +1,255 @@ +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("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); + }); +}); diff --git a/tests/server/checker/runner/cpu/expect.test.ts b/tests/server/checker/runner/cpu/expect.test.ts new file mode 100644 index 0000000..b592d8a --- /dev/null +++ b/tests/server/checker/runner/cpu/expect.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/runner/cpu/normalize.test.ts b/tests/server/checker/runner/cpu/normalize.test.ts new file mode 100644 index 0000000..8ef2f82 --- /dev/null +++ b/tests/server/checker/runner/cpu/normalize.test.ts @@ -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)["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)["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; + expect(expectObj["idlePercent"]).toEqual({ equals: 15 }); + expect(expectObj["maxCoreUsagePercent"]).toEqual({ lte: 95 }); + expect(expectObj["usagePercent"]).toEqual({ equals: 85 }); + }); +}); diff --git a/tests/server/checker/runner/cpu/schema.test.ts b/tests/server/checker/runner/cpu/schema.test.ts new file mode 100644 index 0000000..853463b --- /dev/null +++ b/tests/server/checker/runner/cpu/schema.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/runner/cpu/validate.test.ts b/tests/server/checker/runner/cpu/validate.test.ts new file mode 100644 index 0000000..70021c5 --- /dev/null +++ b/tests/server/checker/runner/cpu/validate.test.ts @@ -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); + }); +}); diff --git a/tests/server/checker/runner/registry.test.ts b/tests/server/checker/runner/registry.test.ts index acd7f8c..b727363 100644 --- a/tests/server/checker/runner/registry.test.ts +++ b/tests/server/checker/runner/registry.test.ts @@ -73,8 +73,20 @@ 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", + "custom", + ]); + expect(second.supportedTypes).toEqual(["http", "cmd", "db", "tcp", "icmp", "udp", "llm", "dns", "ws", "cpu"]); expect( first.definitions.every((checker) => checker.schemas.authoring.config && checker.schemas.normalized.expect), ).toBe(true);