diff --git a/crates/sbm_native/src/sysinfo_backend.rs b/crates/sbm_native/src/sysinfo_backend.rs index 3eba98ae5..71416c040 100644 --- a/crates/sbm_native/src/sysinfo_backend.rs +++ b/crates/sbm_native/src/sysinfo_backend.rs @@ -260,10 +260,8 @@ pub fn sample(state: &mut State) -> ServerStatus { let usage = d.usage(); DiskIoPiece { dev: id.clone(), - // Genuinely cumulative (sysinfo's total_*_bytes), unlike the - // Windows script path's diskio which is a rate mislabeled as - // sectors (see ServerStatus.diskio's doc comment) — this - // native path doesn't inherit that mismatch + // Keep the shared 512-byte cumulative-counter contract used + // by the script parsers and the app's rolling delta model. sectors_read: (usage.total_read_bytes / 512) as i64, sectors_write: (usage.total_written_bytes / 512) as i64, } diff --git a/crates/sbm_parser/src/commands.rs b/crates/sbm_parser/src/commands.rs index 7fe251694..05bc61b40 100644 --- a/crates/sbm_parser/src/commands.rs +++ b/crates/sbm_parser/src/commands.rs @@ -261,7 +261,11 @@ pub const WINDOWS: &[CommandSpec] = &[ CommandSpec { key: HOST, cmd: r#"Write-Output $env:COMPUTERNAME"# }, CommandSpec { key: DISKIO, - cmd: r#"$s1 = @(Get-WmiObject Win32_PerfRawData_PerfDisk_PhysicalDisk | Select-Object Name, DiskReadBytesPersec, DiskWriteBytesPersec, Timestamp_Sys100NS); Start-Sleep -Seconds 1; $s2 = @(Get-WmiObject Win32_PerfRawData_PerfDisk_PhysicalDisk | Select-Object Name, DiskReadBytesPersec, DiskWriteBytesPersec, Timestamp_Sys100NS); @($s1, $s2) | ConvertTo-Json -Depth 5"#, + // PerfRawData fields are cumulative despite their `Persec` names. A + // single logical-disk sample therefore has the same semantics as + // Linux `/proc/diskstats`, and its `C:` keys line up with the disk + // usage command instead of requiring a physical-to-logical mapping. + cmd: "Get-WmiObject Win32_PerfRawData_PerfDisk_LogicalDisk | Select-Object Name, DiskReadBytesPersec, DiskWriteBytesPersec | ConvertTo-Json", }, CommandSpec { key: BATTERY, diff --git a/crates/sbm_parser/src/types.rs b/crates/sbm_parser/src/types.rs index 20dbbcb84..b26ccad6e 100644 --- a/crates/sbm_parser/src/types.rs +++ b/crates/sbm_parser/src/types.rs @@ -271,24 +271,11 @@ pub struct Conn { pub fail: i64, } -/// Cumulative disk IO sector counters (Dart `DiskIOPiece`; no timestamp — recorded by the caller per sample) +/// Cumulative disk IO counters in 512-byte sectors (Dart `DiskIOPiece`; no +/// timestamp — recorded by the caller per sample). /// -/// KNOWN CROSS-PLATFORM SEMANTIC MISMATCH (not fixed, only documented — see -/// `monitor/CLAUDE.md`'s "已知的跨平台语义差异" section): despite the name and -/// doc, `sectors_read`/`sectors_write` are NOT the same kind of value on every -/// platform. -/// - Linux (`linux::parse_diskio`): genuine cumulative sector counters read -/// straight from `/proc/diskstats` — a true "since boot" total. -/// - Windows (`windows::parse_diskio`): the source command already samples -/// WMI twice one second apart and computes a bytes/sec *rate*, which is -/// then divided by 512 and stored into these same "sectors" fields — i.e. -/// Windows silently returns an instantaneous rate, not a cumulative count. -/// -/// Any caller diffing two samples to compute a rate (as this crate's design -/// doc at the top of `lib.rs` assumes for all "raw counters") will -/// double-differentiate Windows data. Not currently an issue because -/// `monitor` only displays the raw value directly (no delta), but a future -/// caller must branch on `SystemType` before doing arithmetic on this field. +/// Linux reads these from `/proc/diskstats`; Windows reads the cumulative byte +/// fields from `Win32_PerfRawData_PerfDisk_LogicalDisk` and divides by 512. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DiskIoPiece { pub dev: String, diff --git a/crates/sbm_parser/src/windows.rs b/crates/sbm_parser/src/windows.rs index 1d510aed4..0a7b6fada 100644 --- a/crates/sbm_parser/src/windows.rs +++ b/crates/sbm_parser/src/windows.rs @@ -293,15 +293,27 @@ pub fn parse_batteries(raw: &str) -> Vec { .collect() } -/// WMI disk IO two-sample delta (Dart `_parseWindowsDiskIO`): -/// rates converted to sector counts (512B), aligned with Linux diskstats counters +/// Cumulative WMI logical-disk byte counters, converted to the 512-byte sector +/// units used by Linux `/proc/diskstats` and the app's rolling delta model. pub fn parse_diskio(raw: &str) -> Vec { - parse_wmi_delta(raw, "DiskReadBytesPersec", "DiskWriteBytesPersec") + let Some(json) = decode(raw) else { + return Vec::new(); + }; + as_list(json) .into_iter() - .map(|(name, read, write)| DiskIoPiece { - dev: name, - sectors_read: (read / 512.0).round() as i64, - sectors_write: (write / 512.0).round() as i64, + .filter_map(|disk| { + let name = disk["Name"].as_str()?.trim(); + let bytes = name.as_bytes(); + if bytes.len() != 2 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' { + return None; + } + let read = json_u64(&disk["DiskReadBytesPersec"])? / 512; + let write = json_u64(&disk["DiskWriteBytesPersec"])? / 512; + Some(DiskIoPiece { + dev: name.to_ascii_uppercase(), + sectors_read: i64::try_from(read).ok()?, + sectors_write: i64::try_from(write).ok()?, + }) }) .collect() } diff --git a/crates/sbm_parser/tests/dart_compat.rs b/crates/sbm_parser/tests/dart_compat.rs index 24ed09478..34e9450f4 100644 --- a/crates/sbm_parser/tests/dart_compat.rs +++ b/crates/sbm_parser/tests/dart_compat.rs @@ -931,6 +931,24 @@ fn diskio_parse() { assert_eq!(pieces[1].dev, "sda"); } +#[test] +fn diskio_parse_windows_cumulative_logical_drives() { + let raw = r#"[ + {"Name":"HarddiskVolume1","DiskReadBytesPersec":1024,"DiskWriteBytesPersec":2048}, + {"Name":"c:","DiskReadBytesPersec":4096,"DiskWriteBytesPersec":8192}, + {"Name":"D:","DiskReadBytesPersec":"16384","DiskWriteBytesPersec":"32768"}, + {"Name":"_Total","DiskReadBytesPersec":99999,"DiskWriteBytesPersec":99999} + ]"#; + let pieces = windows::parse_diskio(raw); + assert_eq!(pieces.len(), 2); + assert_eq!(pieces[0].dev, "C:"); + assert_eq!(pieces[0].sectors_read, 8); + assert_eq!(pieces[0].sectors_write, 16); + assert_eq!(pieces[1].dev, "D:"); + assert_eq!(pieces[1].sectors_read, 32); + assert_eq!(pieces[1].sectors_write, 64); +} + // ---------- Battery: battery_test.dart ---------- /// Dart 'parse battery': all 7 power_supply blocks parsed (no filtering) diff --git a/crates/sbm_parser/tests/script_compat.rs b/crates/sbm_parser/tests/script_compat.rs index 5816d299a..cc49b1496 100644 --- a/crates/sbm_parser/tests/script_compat.rs +++ b/crates/sbm_parser/tests/script_compat.rs @@ -633,9 +633,10 @@ fn extended_commands_split_out_of_status_windows() { assert!(!status.contains("amd-smi")); assert!(ext.contains("Get-StorageReliabilityCounter")); assert!(ext.contains("amd-smi")); - // The Windows disk-IO sample costs two seconds of Start-Sleep but feeds a - // live chart, so it stays in the fast poll - assert!(status.contains("Win32_PerfRawData_PerfDisk_PhysicalDisk")); + // Disk I/O is a single cumulative sample, so the app computes exactly one + // delta between status polls and the keys match Win32_LogicalDisk. + assert!(status.contains("Win32_PerfRawData_PerfDisk_LogicalDisk")); + assert!(!status.contains("Win32_PerfRawData_PerfDisk_PhysicalDisk")); } /// Disabling every command of one half must not emit an empty `then`/`else` diff --git a/lib/data/model/app/scripts/script_consts.dart b/lib/data/model/app/scripts/script_consts.dart index 81665321a..6650da2bf 100644 --- a/lib/data/model/app/scripts/script_consts.dart +++ b/lib/data/model/app/scripts/script_consts.dart @@ -22,7 +22,7 @@ class ScriptConstants { /// and then a hand-maintained number in *two* files with a test holding them /// level, because `fl_build` regenerates `BuildData` and drops anything it /// was not fed. - static const int version = 78; + static const int version = 79; static const String scriptFile = 'srvboxm_v$version.sh'; static const String scriptFileWindows = 'srvboxm_v$version.ps1'; diff --git a/lib/data/model/server/disk.dart b/lib/data/model/server/disk.dart index 42a3564f8..2fdd6995c 100644 --- a/lib/data/model/server/disk.dart +++ b/lib/data/model/server/disk.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:fl_lib/fl_lib.dart'; +import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/model/server/time_seq.dart'; @@ -73,12 +74,14 @@ class Disk extends Equatable { } class DiskIO extends TimeSeq { - DiskIO(); + DiskIO({SystemType system = SystemType.linux}) : _system = system; - DiskIO.copy(DiskIO source) : super.copy(source) { + DiskIO.copy(DiskIO source) : _system = source._system, super.copy(source) { cachedAllSpeed = source.cachedAllSpeed; } + SystemType _system; + /// `/proc/diskstats` reports in 512-byte units regardless of the drive's /// physical sector size static const _sectorBytes = 512; @@ -98,6 +101,11 @@ class DiskIO extends TimeSeq { cachedAllSpeed = (_fmt(read), _fmt(write)); } + void updateForSystem(List next, SystemType system) { + _system = system; + update(next); + } + /// Bytes per second for [dev]. Either both components are present or both /// are `null`: a window with no baseline, no elapsed time, or counters that /// went backwards has no rate at all. The old code divided by that @@ -128,7 +136,14 @@ class DiskIO extends TimeSeq { (double?, double?) get allSpeedBytes { double? read, write; for (final item in now) { - if (!_devPrefixes.any(item.dev.startsWith)) continue; + // `/proc/diskstats` also contains loop, ram and device-mapper rows; the + // Linux prefix filter keeps those out. Windows and BSD samplers already + // return the logical disks the UI shows, whose names do not use Linux + // block-device prefixes (`C:`, `D:`, mount paths, ...). + if (_system == SystemType.linux && + !_devPrefixes.any(item.dev.startsWith)) { + continue; + } final (r, w) = speedBytes(item.dev); if (r == null || w == null) continue; read = (read ?? 0) + r; diff --git a/lib/data/model/server/monitor_metrics_mapper.dart b/lib/data/model/server/monitor_metrics_mapper.dart index fdabae56e..ee9116086 100644 --- a/lib/data/model/server/monitor_metrics_mapper.dart +++ b/lib/data/model/server/monitor_metrics_mapper.dart @@ -353,7 +353,7 @@ void _applyDiskIO(ServerStatus ss, MonitorMetrics m, int time) { ), ) .toList(); - ss.diskIO.update(pieces); + ss.diskIO.updateForSystem(pieces, ss.system); } void _applyBatteries(ServerStatus ss, MonitorMetrics m) { diff --git a/lib/data/model/server/server_status_update_req.dart b/lib/data/model/server/server_status_update_req.dart index 5aa149e51..377180302 100644 --- a/lib/data/model/server/server_status_update_req.dart +++ b/lib/data/model/server/server_status_update_req.dart @@ -351,7 +351,7 @@ void _applyDiskIO( ) .toList(); if (pieces.isNotEmpty) { - ss.diskIO.update(pieces); + ss.diskIO.updateForSystem(pieces, system); } } diff --git a/lib/data/provider/server/single.dart b/lib/data/provider/server/single.dart index b767b9543..be9730317 100644 --- a/lib/data/provider/server/single.dart +++ b/lib/data/provider/server/single.dart @@ -1406,8 +1406,13 @@ class ServerNotifier extends _$ServerNotifier { bool isWindows = false, }) async { final spi = state.spi; + // Windows PowerShell serializes progress and information records (including + // Write-Host) as CLIXML on stderr when invoked with -EncodedCommand. Status + // parsing is a stdout protocol: merging stderr lets those records land in + // whichever SrvBoxSep section happened to be current when the SSH chunks + // arrived, so an document could become the system name or an IP. final execResult = await client - .run(statusCmd) + .run(statusCmd, stderr: false) .timeout(const Duration(seconds: 30)); return SSHDecoder.decode( execResult, diff --git a/lib/view/page/server/tab/content.dart b/lib/view/page/server/tab/content.dart index 9de735fc6..2a53cc70c 100644 --- a/lib/view/page/server/tab/content.dart +++ b/lib/view/page/server/tab/content.dart @@ -149,8 +149,8 @@ ${ss.err?.message ?? 'null'} return FadeTransition(opacity: animation, child: child); }, child: _buildIOData( - isSpeed ? '${l10n.read}:\n$r' : 'Total:\n$total', - isSpeed ? '${l10n.write}:\n$w' : 'Used:\n$used', + isSpeed ? '${l10n.read}:\n${r ?? '--'}' : 'Total:\n$total', + isSpeed ? '${l10n.write}:\n${w ?? '--'}' : 'Used:\n$used', onTap: () { cardNoti.value = v.copyWith(diskIO: !isSpeed); }, diff --git a/monitor/CLAUDE.md b/monitor/CLAUDE.md index a53d74fbc..b88d16c99 100644 --- a/monitor/CLAUDE.md +++ b/monitor/CLAUDE.md @@ -78,11 +78,10 @@ Pure parsing library shared with the Flutter app via FFI (see the "Monorepo Layo Several `ServerStatus` fields share one struct shape across `SystemType::{Linux,Bsd,Windows}` but carry different semantics per platform. Fixing these would change how already-deployed instances' historical data reads, so each is deliberately left as-is and only documented: - **`cpu` (`CpuCore.user`/`idle`/...)**: Linux = real cumulative `/proc/stat` ticks (delta-over-time is correct); Bsd = an instantaneous percentage stored directly into the tick fields (never delta — the raw value already is the percentage); Windows = an instantaneous percentage *accumulated* onto the previous sample into a synthetic monotonic counter (`windows::parse_cpu`'s `prev` param). Three incompatible interpretations of the same fields — `monitor::monitoring::adapt_cpu` already branches on `SystemType` correctly; any new consumer must too. -- **`diskio` (`DiskIoPiece.sectors_read`/`sectors_write`)**: Linux = genuine cumulative sector counters (`/proc/diskstats`); Windows = already-computed bytes/sec rate divided by 512 and stored in the same "sectors" fields — not a cumulative count at all. A naive delta-over-two-samples on Windows data double-differentiates. - **`sys`**: Linux uses a real distro-description parser (`common::parse_sys_version`, extracts `PRETTY_NAME`); Bsd/Windows repurpose the generic hostname-trimming helper (`common::parse_hostname`) against `uname -or`/`OsName` output — happens to work because those are single clean lines, but isn't a "system version" parser on those platforms. The `os_id`/`os_id_like` fields beside it come out of the same command and are Linux-only for the same reason: they are `/etc/os-release`'s `ID=`/`ID_LIKE=`, which Bsd/Windows have no equivalent of. `capabilities::Capabilities` has no entry for them — they share `sys`'s by construction. - **`uptime`**: Linux/Bsd normalize the `uptime` command's varied output via `common::parse_uptime`; Windows pre-formats the duration string in PowerShell itself and the field just passes it through — presentation shape isn't guaranteed identical across platforms. -The shell-script collection path has been replaced with native per-platform sampling for the fields it can cover (`crates/sbm_native` — see below); this incidentally resolved both the `cpu` mismatch (`sysinfo`'s CPU percentage has consistent semantics across platforms) and the `diskio` mismatch (`sysinfo::Disk::usage()` is genuinely cumulative everywhere, unlike the old Windows script path's rate-mislabeled-as-sectors bug). These fixes only apply to **monitor's own native path** — `sbm_parser`'s script-based output (still used by the SSH-based Flutter app, and by monitor's own extended-cycle script for amd/sensors/SMART/battery) still has the documented mismatches, since changing that shared, App-facing behavior is out of scope here. `sys`/`uptime` still differ in string shape between native and script sources (unchanged — see their doc comments). +The shell-script collection path has been replaced with native per-platform sampling for the fields it can cover (`crates/sbm_native` — see below); this resolved the `cpu` mismatch for monitor itself because `sysinfo`'s CPU percentage has consistent semantics across platforms. The shared script path still carries the documented CPU distinction, while `diskio` now uses genuinely cumulative 512-byte counters on both the native and script paths. `sys`/`uptime` still differ in string shape between native and script sources (unchanged — see their doc comments). ### Native Sampler (`../crates/sbm_native/`, monorepo root) diff --git a/test/disk_test.dart b/test/disk_test.dart index 4e7e6bc15..6f06ed0e6 100644 --- a/test/disk_test.dart +++ b/test/disk_test.dart @@ -2,9 +2,51 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:server_box/data/model/server/disk.dart'; +import 'package:server_box/data/model/server/system.dart'; // Parsing tests migrated to crates/sbm_parser/tests/dart_compat.rs void main() { + group('DiskIO', () { + DiskIOPiece piece(String dev, int read, int write, int time) => + DiskIOPiece( + dev: dev, + sectorsRead: read, + sectorsWrite: write, + time: time, + ); + + test('Windows aggregates logical drives after the second sample', () { + final io = DiskIO(); + io.updateForSystem( + [piece('C:', 100, 200, 10), piece('D:', 300, 400, 10)], + SystemType.windows, + ); + expect(io.allSpeedBytes, (null, null)); + + io.updateForSystem( + [piece('C:', 104, 206, 12), piece('D:', 310, 408, 12)], + SystemType.windows, + ); + + expect(io.speedBytes('C:'), (1024.0, 1536.0)); + expect(io.allSpeedBytes, (3584.0, 3584.0)); + }); + + test('Linux aggregate still excludes virtual block devices', () { + final io = DiskIO(); + io.updateForSystem( + [piece('sda', 10, 20, 1), piece('loop0', 1000, 2000, 1)], + SystemType.linux, + ); + io.updateForSystem( + [piece('sda', 12, 24, 2), piece('loop0', 2000, 4000, 2)], + SystemType.linux, + ); + + expect(io.allSpeedBytes, (1024.0, 2048.0)); + }); + }); + group('DiskUsage', () { test('DiskUsage does not double-count parent and child filesystems', () { final usage = DiskUsage.parse([