From b0cb6bfb69746d2873cee797f59151a884cd8f93 Mon Sep 17 00:00:00 2001 From: SunilWang Date: Tue, 7 Apr 2026 19:52:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=89=88=E6=9C=AC=202.0.3?= =?UTF-8?q?=EF=BC=9A=E4=BF=AE=E5=A4=8D=20df=20=E6=9D=83=E9=99=90=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=8F=8A=E6=96=B0=E5=A2=9E=20excludeIowait=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 #37:Linux 下 df 遇到 FUSE 挂载点(如 /run/user/1000/doc)权限不足 时不再整体失败,只要 stdout 有有效数据即正常返回其余磁盘信息 - 解决 #39:CPUConfig 新增 excludeIowait 选项(默认 false 保持现有行为), 设为 true 时从 overall 使用率中剔除 iowait,适合 I/O 密集型场景 - 补充对应单元测试(linux-adapter: 4 个,cpu-monitor: 4 个) - 同步更新 README.md 与 README-zh.md Co-Authored-By: Claude Sonnet 4.6 --- README-zh.md | 26 ++++++- README.md | 26 ++++++- package.json | 2 +- src/adapters/linux-adapter.ts | 10 ++- src/monitors/cpu-monitor.ts | 10 ++- src/types/config.ts | 8 ++ test/unit/adapters/linux-adapter.test.ts | 87 ++++++++++++++++++++++ test/unit/monitors/cpu-monitor.test.ts | 94 ++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 6 deletions(-) diff --git a/README-zh.md b/README-zh.md index 4dd25a7..1ad8707 100644 --- a/README-zh.md +++ b/README-zh.md @@ -167,7 +167,12 @@ const osutils = new OSUtils({ debug: false, // 监控器特定配置 - cpu: { cacheTTL: 30000 }, + cpu: { + cacheTTL: 30000, + // 是否将 iowait 从整体 CPU 使用率中排除(仅 Linux 生效) + // 默认 false:iowait 计入 overall,与传统监控工具行为一致 + excludeIowait: false + }, memory: { cacheTTL: 5000 }, disk: { cacheTTL: 60000 } }); @@ -303,6 +308,25 @@ if (loadAvg.success) { | `getCacheInfo()` | `Promise>` | CPU 缓存层级信息 | ⚠️ 有限 | | `coreCount()` | `Promise>` | 物理/逻辑核心数量 | ✅ 全部 | +#### CPU 配置项 + +| 配置项 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| `excludeIowait` | `boolean` | `false` | 为 `true` 时,I/O 等待时间(iowait)将从 `overall` 使用率中剔除,适合 I/O 密集型场景下避免 CPU 使用率虚高。`iowait` 仍作为独立字段在 `usageDetailed()` 中返回。仅 Linux 生效。 | + +```typescript +// 在 I/O 密集型 Linux 环境中排除 iowait +const osutils = new OSUtils({ + cpu: { excludeIowait: true } +}); + +const result = await osutils.cpu.usageDetailed(); +if (result.success) { + console.log('整体使用率(不含 iowait):', result.data.overall + '%'); + console.log('iowait:', result.data.iowait + '%'); // 仍可单独读取 +} +``` + #### 实时 CPU 监控 ```typescript diff --git a/README.md b/README.md index 4f70806..7e9b5d6 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,12 @@ const osutils = new OSUtils({ debug: false, // Monitor-specific configurations - cpu: { cacheTTL: 30000 }, + cpu: { + cacheTTL: 30000, + // Exclude iowait from the overall CPU usage percentage (Linux only). + // Default: false (iowait is included, matching traditional tool behavior) + excludeIowait: false + }, memory: { cacheTTL: 5000 }, disk: { cacheTTL: 60000 } }); @@ -303,6 +308,25 @@ if (loadAvg.success) { | `getCacheInfo()` | `Promise>` | CPU cache hierarchy information | ⚠️ Limited | | `coreCount()` | `Promise>` | Physical/logical core counts | ✅ All | +#### CPU Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `excludeIowait` | `boolean` | `false` | When `true`, I/O wait time is excluded from the `overall` CPU usage percentage. Useful in I/O-heavy environments where iowait would otherwise inflate reported CPU usage. `iowait` is still available as a separate field in `usageDetailed()`. Linux only. | + +```typescript +// Exclude iowait from overall CPU usage (Linux I/O-heavy workloads) +const osutils = new OSUtils({ + cpu: { excludeIowait: true } +}); + +const result = await osutils.cpu.usageDetailed(); +if (result.success) { + console.log('Overall (excl. iowait):', result.data.overall + '%'); + console.log('iowait:', result.data.iowait + '%'); // still available +} +``` + #### Real-time CPU Monitoring ```typescript diff --git a/package.json b/package.json index 0233ef3..083b05b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-os-utils", - "version": "2.0.2", + "version": "2.0.3", "description": "Advanced cross-platform operating system monitoring utilities with TypeScript support", "type": "commonjs", "main": "dist/src/index.js", diff --git a/src/adapters/linux-adapter.ts b/src/adapters/linux-adapter.ts index 32735e3..b69a7ee 100644 --- a/src/adapters/linux-adapter.ts +++ b/src/adapters/linux-adapter.ts @@ -238,7 +238,11 @@ export class LinuxAdapter extends BasePlatformAdapter { async getDiskInfo(): Promise { try { const result = await this.executeCommand('df -h'); - this.validateCommandResult(result, 'df -h'); + // df 遇到无权限挂载点(如 /run/user/1000/doc FUSE 挂载)会以 exit code 1 退出, + // 但 stdout 仍包含其余挂载点的完整数据。只要有可解析的输出就继续处理。 + if (!result.stdout || result.stdout.trim().split('\n').length < 2) { + this.validateCommandResult(result, 'df -h'); + } return this.parseDiskInfo(result.stdout); } catch (error) { throw this.createCommandError('getDiskInfo', error); @@ -967,6 +971,10 @@ export class LinuxAdapter extends BasePlatformAdapter { async getDiskUsage(): Promise { try { const result = await this.executeCommand('df -B1'); + // 同 getDiskInfo:df 遇到无权限挂载点时 exit code 为 1,但 stdout 数据仍有效 + if (!result.stdout || result.stdout.trim().split('\n').length < 2) { + this.validateCommandResult(result, 'df -B1'); + } return this.parseDiskUsage(result.stdout); } catch (error) { throw this.createCommandError('getDiskUsage', error); diff --git a/src/monitors/cpu-monitor.ts b/src/monitors/cpu-monitor.ts index f3b0960..0a60057 100644 --- a/src/monitors/cpu-monitor.ts +++ b/src/monitors/cpu-monitor.ts @@ -300,13 +300,19 @@ export class CPUMonitor extends BaseMonitor { * 转换 CPU 使用率信息 */ private transformCPUUsage(rawData: any): CPUUsage { + const iowait = this.safeParseNumber(rawData.iowait); + const rawOverall = this.safeParseNumber(rawData.overall || rawData.usage); + const overall = this.cpuConfig.excludeIowait + ? Math.max(0, rawOverall - iowait) + : rawOverall; + return { - overall: this.safeParseNumber(rawData.overall || rawData.usage), + overall, cores: rawData.cores || [], user: this.safeParseNumber(rawData.user), system: this.safeParseNumber(rawData.system || rawData.sys), idle: this.safeParseNumber(rawData.idle), - iowait: this.safeParseNumber(rawData.iowait), + iowait, irq: this.safeParseNumber(rawData.irq), softirq: this.safeParseNumber(rawData.softirq) }; diff --git a/src/types/config.ts b/src/types/config.ts index 4e70d3c..0e609e2 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -133,6 +133,14 @@ export interface CPUConfig extends MonitorConfig { * 负载平均值时间窗口(分钟) */ loadAverageWindows?: number[]; + + /** + * 计算 overall CPU 使用率时是否排除 I/O 等待时间(iowait) + * + * - `false`(默认):iowait 计入 overall,与大多数传统监控工具行为一致 + * - `true`:iowait 从 overall 中剔除,反映 CPU 实际计算负载(适合 I/O 密集型场景) + */ + excludeIowait?: boolean; } /** diff --git a/test/unit/adapters/linux-adapter.test.ts b/test/unit/adapters/linux-adapter.test.ts index db7f7fc..f09347e 100644 --- a/test/unit/adapters/linux-adapter.test.ts +++ b/test/unit/adapters/linux-adapter.test.ts @@ -193,6 +193,93 @@ describe('LinuxAdapter 内部解析逻辑', () => { expect(result.model).to.be.a('string').and.to.have.length.greaterThan(0); }); + // ——— #37 修复:df 遇到无权限挂载点时不应整体失败 ——— + + it('getDiskInfo() 在 df 遇到权限错误但 stdout 有效时应正常返回磁盘列表', async () => { + const adapter = new LinuxAdapter(); + + (adapter as any).executeCommand = async () => ({ + stdout: [ + 'Filesystem Size Used Avail Use% Mounted on', + '/dev/sda1 50G 20G 30G 40% /', + '/dev/sdb1 100G 60G 40G 60% /data' + ].join('\n'), + stderr: 'df: /run/user/1000/doc: Operation not permitted', + exitCode: 1, + platform: 'linux', + executionTime: 5, + command: 'df -h' + }); + + const result = await adapter.getDiskInfo(); + expect(result).to.be.an('array').with.lengthOf(2); + expect(result[0].mountpoint).to.equal('/'); + expect(result[1].mountpoint).to.equal('/data'); + }); + + it('getDiskInfo() 在 df stdout 为空时应抛出错误', async () => { + const adapter = new LinuxAdapter(); + + (adapter as any).executeCommand = async () => ({ + stdout: '', + stderr: 'df: command not found', + exitCode: 127, + platform: 'linux', + executionTime: 0, + command: 'df -h' + }); + + try { + await adapter.getDiskInfo(); + expect.fail('应该抛出 MonitorError'); + } catch (error: any) { + expect(error).to.be.instanceOf(MonitorError); + } + }); + + it('getDiskUsage() 在 df 遇到权限错误但 stdout 有效时应正常返回磁盘列表', async () => { + const adapter = new LinuxAdapter(); + + (adapter as any).executeCommand = async () => ({ + stdout: [ + 'Filesystem 1B-blocks Used Available Use% Mounted on', + '/dev/sda1 53687091200 21474836480 32212254720 40% /', + '/dev/sdb1 107374182400 64424509440 42949672960 60% /data' + ].join('\n'), + stderr: 'df: /run/user/1000/doc: Operation not permitted', + exitCode: 1, + platform: 'linux', + executionTime: 5, + command: 'df -B1' + }); + + const result = await adapter.getDiskUsage(); + expect(result).to.be.an('array').with.lengthOf(2); + expect(result[0].mountPoint).to.equal('/'); + expect(result[1].mountPoint).to.equal('/data'); + expect(result[0].usagePercentage).to.equal(40); + }); + + it('getDiskUsage() 在 df stdout 为空时应抛出错误', async () => { + const adapter = new LinuxAdapter(); + + (adapter as any).executeCommand = async () => ({ + stdout: '', + stderr: 'df: command not found', + exitCode: 127, + platform: 'linux', + executionTime: 0, + command: 'df -B1' + }); + + try { + await adapter.getDiskUsage(); + expect.fail('应该抛出 MonitorError'); + } catch (error: any) { + expect(error).to.be.instanceOf(MonitorError); + } + }); + it('应在 ss 不可用时回退到 netstat 解析连接', async () => { const adapter = new LinuxAdapter(); const internal = adapter as any; diff --git a/test/unit/monitors/cpu-monitor.test.ts b/test/unit/monitors/cpu-monitor.test.ts index 3c231a4..7b87e71 100644 --- a/test/unit/monitors/cpu-monitor.test.ts +++ b/test/unit/monitors/cpu-monitor.test.ts @@ -179,6 +179,100 @@ describe('CPUMonitor', () => { }); }); +describe('CPUMonitor — excludeIowait 配置项', () => { + // adapter 返回 overall=50, iowait=5 + const usageWithIowait = { + overall: 50, + cores: [], + user: 30, + system: 15, + idle: 50, + iowait: 5, + irq: 0, + softirq: 0 + }; + + function createAdapter(): PlatformAdapter { + return { + getPlatform: () => 'linux', + isSupported: () => true, + executeCommand: async () => ({ stdout: '', stderr: '', exitCode: 0, platform: 'linux', executionTime: 0, command: '' }), + readFile: async () => '', + fileExists: async () => true, + getCPUInfo: async () => ({}), + getCPUUsage: async () => usageWithIowait, + getCPUTemperature: async () => [], + getMemoryInfo: async () => ({}), + getMemoryUsage: async () => ({}), + getDiskInfo: async () => ({}), + getDiskIO: async () => ({}), + getNetworkInterfaces: async () => ({}), + getNetworkStats: async () => ({}), + getProcesses: async () => [], + getProcessInfo: async () => ({}), + getSystemInfo: async () => ({}), + getSystemLoad: async () => ({ load1: 0, load5: 0, load15: 0 }), + getDiskUsage: async () => ({}), + getDiskStats: async () => ({}), + getMounts: async () => ({}), + getFileSystems: async () => ({}), + getNetworkConnections: async () => [], + getDefaultGateway: async () => ({}), + getProcessList: async () => [], + killProcess: async () => true, + getProcessOpenFiles: async () => [], + getProcessEnvironment: async () => ({}), + getSystemUptime: async () => ({}), + getSystemUsers: async () => [], + getSystemServices: async () => [], + getSupportedFeatures: () => ({ + cpu: { info: true, usage: true, temperature: false, frequency: false, cache: false, perCore: false, cores: false }, + memory: { info: true, usage: true, swap: false, pressure: false, detailed: false, virtual: false }, + disk: { info: true, io: false, health: false, smart: false, filesystem: false, usage: true, stats: false, mounts: false, filesystems: false }, + network: { interfaces: false, stats: false, connections: false, bandwidth: false, gateway: false }, + process: { list: false, details: false, tree: false, monitor: false, info: false, kill: false, openFiles: false, environment: false }, + system: { info: false, load: false, uptime: false, users: false, services: false } + }) + } as PlatformAdapter; + } + + it('默认(excludeIowait=false):overall 包含 iowait', async () => { + const monitor = new CPUMonitor(createAdapter()); + const result = await monitor.usageDetailed(); + expect(result.success).to.be.true; + if (!result.success) return; + expect(result.data.overall).to.equal(50); + expect(result.data.iowait).to.equal(5); + }); + + it('excludeIowait=true:overall 应减去 iowait', async () => { + const monitor = new CPUMonitor(createAdapter(), { excludeIowait: true }); + const result = await monitor.usageDetailed(); + expect(result.success).to.be.true; + if (!result.success) return; + expect(result.data.overall).to.equal(45); // 50 - 5 + expect(result.data.iowait).to.equal(5); // iowait 字段本身不受影响 + }); + + it('excludeIowait=true:usage() 简单接口也同步排除 iowait', async () => { + const monitor = new CPUMonitor(createAdapter(), { excludeIowait: true }); + const result = await monitor.usage(); + expect(result.success).to.be.true; + if (!result.success) return; + expect(result.data).to.equal(45); + }); + + it('excludeIowait=true:iowait 大于 overall 时 overall 不应为负数', async () => { + const adapter = createAdapter(); + (adapter as any).getCPUUsage = async () => ({ overall: 3, iowait: 5, cores: [], user: 0, system: 0, idle: 97, irq: 0, softirq: 0 }); + const monitor = new CPUMonitor(adapter, { excludeIowait: true }); + const result = await monitor.usageDetailed(); + expect(result.success).to.be.true; + if (!result.success) return; + expect(result.data.overall).to.equal(0); + }); +}); + describe('CPUMonitor — Deno 兼容性降级', () => { function createFailingAdapter(): PlatformAdapter { const base = {