Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions crates/sbm_native/src/sysinfo_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
6 changes: 5 additions & 1 deletion crates/sbm_parser/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 4 additions & 17 deletions crates/sbm_parser/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 19 additions & 7 deletions crates/sbm_parser/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,15 +293,27 @@ pub fn parse_batteries(raw: &str) -> Vec<Battery> {
.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<DiskIoPiece> {
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()
}
Expand Down
18 changes: 18 additions & 0 deletions crates/sbm_parser/tests/dart_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions crates/sbm_parser/tests/script_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion lib/data/model/app/scripts/script_consts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
21 changes: 18 additions & 3 deletions lib/data/model/server/disk.dart
Original file line number Diff line number Diff line change
@@ -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';


Expand Down Expand Up @@ -73,12 +74,14 @@ class Disk extends Equatable {
}

class DiskIO extends TimeSeq<DiskIOPiece> {
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;
Expand All @@ -98,6 +101,11 @@ class DiskIO extends TimeSeq<DiskIOPiece> {
cachedAllSpeed = (_fmt(read), _fmt(write));
}

void updateForSystem(List<DiskIOPiece> 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
Expand Down Expand Up @@ -128,7 +136,14 @@ class DiskIO extends TimeSeq<DiskIOPiece> {
(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;
Expand Down
2 changes: 1 addition & 1 deletion lib/data/model/server/monitor_metrics_mapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion lib/data/model/server/server_status_update_req.dart
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ void _applyDiskIO(
)
.toList();
if (pieces.isNotEmpty) {
ss.diskIO.update(pieces);
ss.diskIO.updateForSystem(pieces, system);
}
}

Expand Down
7 changes: 6 additions & 1 deletion lib/data/provider/server/single.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Objs> 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,
Expand Down
4 changes: 2 additions & 2 deletions lib/view/page/server/tab/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
3 changes: 1 addition & 2 deletions monitor/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
42 changes: 42 additions & 0 deletions test/disk_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading