From 8e4c2a7cded8a89e818d18846d6a3e7138ceb02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 1 Sep 2025 23:32:20 +0800 Subject: [PATCH] fix: fallback to `df` on incompatible system (#880) --- lib/data/model/app/scripts/cmd_types.dart | 4 +- lib/data/model/server/disk.dart | 121 ++++++---- test/disk_test.dart | 280 ++++++++++++++++++++++ 3 files changed, 356 insertions(+), 49 deletions(-) diff --git a/lib/data/model/app/scripts/cmd_types.dart b/lib/data/model/app/scripts/cmd_types.dart index ab889476..4bf6f311 100644 --- a/lib/data/model/app/scripts/cmd_types.dart +++ b/lib/data/model/app/scripts/cmd_types.dart @@ -55,8 +55,8 @@ enum StatusCmdType implements ShellCmdType { uptime('uptime'), conn('cat /proc/net/snmp'), disk( - 'lsblk --bytes --json --output ' - 'FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID', + '(lsblk --bytes --json --output ' + 'FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID 2>/dev/null && echo "LSBLK_SUCCESS") || df -k' ), mem("cat /proc/meminfo | grep -E 'Mem|Swap'"), tempType('cat /sys/class/thermal/thermal_zone*/type'), diff --git a/lib/data/model/server/disk.dart b/lib/data/model/server/disk.dart index 2b7f0c7d..3a34527d 100644 --- a/lib/data/model/server/disk.dart +++ b/lib/data/model/server/disk.dart @@ -44,22 +44,49 @@ class Disk with EquatableMixin { static List parse(String raw) { final list = []; raw = raw.trim(); - try { - if (raw.startsWith('{')) { - // Parse JSON output from lsblk command - final Map jsonData = json.decode(raw); - final List blockdevices = jsonData['blockdevices'] ?? []; + + if (raw.isEmpty) { + dprint('Empty disk info data received'); + return list; + } - for (final device in blockdevices) { - // Process each device - _processTopLevelDevice(device, list); + try { + // Check if we have lsblk JSON output with success marker + if (raw.startsWith('{')) { + // Extract JSON part (excluding the success marker if present) + final jsonEnd = raw.indexOf('\nLSBLK_SUCCESS'); + final jsonPart = jsonEnd > 0 ? raw.substring(0, jsonEnd) : raw; + + try { + final Map jsonData = json.decode(jsonPart); + final List blockdevices = jsonData['blockdevices'] ?? []; + + for (final device in blockdevices) { + // Process each device + _processTopLevelDevice(device, list); + } + + // If we successfully parsed JSON and have valid disks, return them + if (list.isNotEmpty) { + return list; + } + } on FormatException catch (e) { + Loggers.app.warning('JSON parsing failed, falling back to df -k output: $e'); + } catch (e) { + Loggers.app.warning('Error processing JSON disk data, falling back to df -k output: $e', e); } - } else { - // Fallback to the old parsing method in case of non-JSON output + } + + // Check if we have df -k output (fallback case) + if (raw.contains('Filesystem') && raw.contains('Mounted on')) { return _parseWithOldMethod(raw); } + + // If we reach here, both parsing methods failed + Loggers.app.warning('Unable to parse disk info with any method'); + } catch (e) { - Loggers.app.warning('Failed to parse disk info: $e', e); + Loggers.app.warning('Failed to parse disk info with both methods: $e', e); } return list; } @@ -88,6 +115,32 @@ class Disk with EquatableMixin { } } + /// Parse filesystem fields from device data + static ({BigInt size, BigInt used, BigInt avail, int usedPercent}) _parseFilesystemFields(Map device) { + // Helper function to parse size strings safely + BigInt parseSize(String? sizeStr) { + if (sizeStr == null || sizeStr.isEmpty || sizeStr == 'null' || sizeStr == '0') { + return BigInt.zero; + } + return (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024); + } + + // Helper function to parse percentage strings + int parsePercent(String? percentStr) { + if (percentStr == null || percentStr.isEmpty || percentStr == 'null') { + return 0; + } + return int.tryParse(percentStr.replaceAll('%', '')) ?? 0; + } + + return ( + size: parseSize(device['fssize']?.toString()), + used: parseSize(device['fsused']?.toString()), + avail: parseSize(device['fsavail']?.toString()), + usedPercent: parsePercent(device['fsuse%']?.toString()), + ); + } + /// Process a single device without recursively processing its children static Disk? _processSingleDevice(Map device) { final fstype = device['fstype']?.toString(); @@ -102,20 +155,7 @@ class Disk with EquatableMixin { return null; } - final sizeStr = device['fssize']?.toString() ?? '0'; - final size = (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024); - - final usedStr = device['fsused']?.toString() ?? '0'; - final used = (BigInt.tryParse(usedStr) ?? BigInt.zero) ~/ BigInt.from(1024); - - final availStr = device['fsavail']?.toString() ?? '0'; - final avail = (BigInt.tryParse(availStr) ?? BigInt.zero) ~/ BigInt.from(1024); - - // Parse fsuse% which is usually in the format "45%" - String usePercentStr = device['fsuse%']?.toString() ?? '0'; - usePercentStr = usePercentStr.replaceAll('%', ''); - final usedPercent = int.tryParse(usePercentStr) ?? 0; - + final fsFields = _parseFilesystemFields(device); final name = device['name']?.toString(); final kname = device['kname']?.toString(); final uuid = device['uuid']?.toString(); @@ -124,10 +164,10 @@ class Disk with EquatableMixin { path: path, fsTyp: fstype, mount: mountpoint, - usedPercent: usedPercent, - used: used, - size: size, - avail: avail, + usedPercent: fsFields.usedPercent, + used: fsFields.used, + size: fsFields.size, + avail: fsFields.avail, name: name, kname: kname, uuid: uuid, @@ -155,20 +195,7 @@ class Disk with EquatableMixin { // Handle common filesystem cases or parent devices with children if ((fstype != null && _shouldCalc(fstype, mount)) || (childDisks.isNotEmpty && path.isNotEmpty)) { - final sizeStr = device['fssize']?.toString() ?? '0'; - final size = (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024); - - final usedStr = device['fsused']?.toString() ?? '0'; - final used = (BigInt.tryParse(usedStr) ?? BigInt.zero) ~/ BigInt.from(1024); - - final availStr = device['fsavail']?.toString() ?? '0'; - final avail = (BigInt.tryParse(availStr) ?? BigInt.zero) ~/ BigInt.from(1024); - - // Parse fsuse% which is usually in the format "45%" - String usePercentStr = device['fsuse%']?.toString() ?? '0'; - usePercentStr = usePercentStr.replaceAll('%', ''); - final usedPercent = int.tryParse(usePercentStr) ?? 0; - + final fsFields = _parseFilesystemFields(device); final name = device['name']?.toString(); final kname = device['kname']?.toString(); final uuid = device['uuid']?.toString(); @@ -177,10 +204,10 @@ class Disk with EquatableMixin { path: path, fsTyp: fstype, mount: mount, - usedPercent: usedPercent, - used: used, - size: size, - avail: avail, + usedPercent: fsFields.usedPercent, + used: fsFields.used, + size: fsFields.size, + avail: fsFields.avail, name: name, kname: kname, uuid: uuid, diff --git a/test/disk_test.dart b/test/disk_test.dart index bb0c04e3..00e465c5 100644 --- a/test/disk_test.dart +++ b/test/disk_test.dart @@ -81,6 +81,138 @@ void main() { expect(usage.usedPercent, 50); // This would use the "unknown" fallback for kname }); + + test('parse df -k output (fallback mode)', () { + final disks = Disk.parse(_dfOutput); + expect(disks, isNotEmpty); + expect(disks.length, 3); // Should find 3 valid filesystems: udev, /dev/vda3, /dev/vda2 + + // Verify root filesystem + final rootFs = disks.firstWhere((disk) => disk.mount == '/'); + expect(rootFs.path, '/dev/vda3'); + expect(rootFs.usedPercent, 47); + expect(rootFs.size, BigInt.from(40910528 ~/ 1024)); // df -k output divided by 1024 = MB + expect(rootFs.used, BigInt.from(18067948 ~/ 1024)); + expect(rootFs.avail, BigInt.from(20951380 ~/ 1024)); + + // Verify boot/efi filesystem + final efiFs = disks.firstWhere((disk) => disk.mount == '/boot/efi'); + expect(efiFs.path, '/dev/vda2'); + expect(efiFs.usedPercent, 7); + expect(efiFs.size, BigInt.from(192559 ~/ 1024)); + + // Verify udev filesystem is included (virtual filesystem) + final udevFs = disks.firstWhere((disk) => disk.path == 'udev'); + expect(udevFs.mount, '/dev'); + expect(udevFs.usedPercent, 0); + expect(udevFs.size, BigInt.from(864088 ~/ 1024)); + }); + + test('handle empty input gracefully', () { + final disks = Disk.parse(''); + expect(disks, isEmpty); + }); + + test('handle whitespace-only input', () { + final disks = Disk.parse(' \n\t \r\n '); + expect(disks, isEmpty); + }); + + test('handle JSON with null filesystem fields', () { + final disks = Disk.parse(_jsonWithNullFields); + expect(disks, isNotEmpty); + + // Should handle null filesystem fields gracefully + final disk = disks.firstWhere((disk) => disk.mount == '/'); + expect(disk.size, BigInt.zero); + expect(disk.used, BigInt.zero); + expect(disk.avail, BigInt.zero); + expect(disk.usedPercent, 0); + }); + + test('handle JSON with string "null" values', () { + final disks = Disk.parse(_jsonWithStringNulls); + expect(disks, isNotEmpty); + + // Should handle string "null" filesystem fields gracefully + final disk = disks.firstWhere((disk) => disk.mount == '/'); + expect(disk.size, BigInt.zero); + expect(disk.used, BigInt.zero); + expect(disk.avail, BigInt.zero); + expect(disk.usedPercent, 0); + }); + + test('handle JSON with empty string values', () { + final disks = Disk.parse(_jsonWithEmptyStrings); + expect(disks, isNotEmpty); + + // Should handle empty string filesystem fields gracefully + final disk = disks.firstWhere((disk) => disk.mount == '/'); + expect(disk.size, BigInt.zero); + expect(disk.used, BigInt.zero); + expect(disk.avail, BigInt.zero); + expect(disk.usedPercent, 0); + }); + + test('handle JSON with invalid percentage format', () { + final disks = Disk.parse(_jsonWithInvalidPercent); + expect(disks, isNotEmpty); + + // Should handle invalid percentage gracefully + final disk = disks.firstWhere((disk) => disk.mount == '/'); + expect(disk.usedPercent, 0); + }); + + test('handle JSON with malformed numbers', () { + final disks = Disk.parse(_jsonWithMalformedNumbers); + expect(disks, isNotEmpty); + + // Should handle malformed numbers gracefully + final disk = disks.firstWhere((disk) => disk.mount == '/'); + expect(disk.size, BigInt.zero); + expect(disk.used, BigInt.zero); + expect(disk.avail, BigInt.zero); + }); + + test('handle JSON parsing errors gracefully', () { + final disks = Disk.parse(_malformedJson); + expect(disks, isEmpty); // Should fallback to legacy method, which also fails + }); + + test('handle df output with missing fields', () { + final disks = Disk.parse(_dfWithMissingFields); + expect(disks, isNotEmpty); + + // Should handle missing fields gracefully + final disk = disks.firstWhere((disk) => disk.mount == '/'); + expect(disk.usedPercent, 47); + }); + + test('handle df output with inconsistent formatting', () { + final disks = Disk.parse(_dfWithInconsistentFormatting); + expect(disks, isNotEmpty); + + // Should handle inconsistent formatting + expect(disks.length, greaterThan(0)); + }); + + test('handle lsblk with success marker', () { + final disks = Disk.parse(_lsblkWithSuccessMarker); + expect(disks, isNotEmpty); + + // Should parse JSON and ignore success marker + final rootFs = disks.firstWhere((disk) => disk.mount == '/'); + expect(rootFs.fsTyp, 'ext4'); + expect(rootFs.usedPercent, 56); + }); + + test('handle malformed lsblk output fallback', () { + final disks = Disk.parse(_malformedLsblkWithDfFallback); + expect(disks, isNotEmpty); + + // Should fallback to df -k parsing when lsblk output is malformed + expect(disks.length, 3); + }); }); } @@ -278,3 +410,151 @@ overlay 1907116416 5470 v2000pro/pve 1906694784 125440 1906569344 1% /mnt/v2000pro/pve v2000pro/download 1906569472 128 1906569344 1% /mnt/v2000pro/download''', ]; + +const _dfOutput = ''' +Filesystem 1K-blocks Used Available Use% Mounted on +udev 864088 0 864088 0% /dev +tmpfs 176724 688 176036 1% /run +/dev/vda3 40910528 18067948 20951380 47% / +tmpfs 883612 0 883612 0% /dev/shm +tmpfs 5120 0 5120 0% /run/lock +/dev/vda2 192559 11807 180752 7% /boot/efi +tmpfs 176720 104 176616 1% /run/user/1000 +'''; + +// Test data for edge cases +const _jsonWithNullFields = ''' +{ + "blockdevices": [ + { + "fstype": "ext4", + "mountpoint": "/", + "fssize": null, + "fsused": null, + "fsavail": null, + "fsuse%": null, + "path": "/dev/sda1" + } + ] +} +'''; + +const _jsonWithStringNulls = ''' +{ + "blockdevices": [ + { + "fstype": "ext4", + "mountpoint": "/", + "fssize": "null", + "fsused": "null", + "fsavail": "null", + "fsuse%": "null", + "path": "/dev/sda1" + } + ] +} +'''; + +const _jsonWithEmptyStrings = ''' +{ + "blockdevices": [ + { + "fstype": "ext4", + "mountpoint": "/", + "fssize": "", + "fsused": "", + "fsavail": "", + "fsuse%": "", + "path": "/dev/sda1" + } + ] +} +'''; + +const _jsonWithInvalidPercent = ''' +{ + "blockdevices": [ + { + "fstype": "ext4", + "mountpoint": "/", + "fssize": "1000000", + "fsused": "500000", + "fsavail": "500000", + "fsuse%": "invalid_percent", + "path": "/dev/sda1" + } + ] +} +'''; + +const _jsonWithMalformedNumbers = ''' +{ + "blockdevices": [ + { + "fstype": "ext4", + "mountpoint": "/", + "fssize": "not_a_number", + "fsused": "invalid", + "fsavail": "broken", + "fsuse%": "50%", + "path": "/dev/sda1" + } + ] +} +'''; + +const _malformedJson = ''' +{ + "blockdevices": [ + { + "fstype": "ext4", + "mountpoint": "/", + "fssize": "1000000", + "fsused": "500000", + "fsavail": "500000", + "fsuse%": "50%", + "path": "/dev/sda1" + } + ] + // Missing closing brace and malformed structure +'''; + +const _dfWithMissingFields = ''' +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/vda3 40910528 18067948 20951380 47% / +'''; + +const _dfWithInconsistentFormatting = ''' +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/sda1 1000000 500000 500000 50% / +/dev/sda2 2000000 1000000 1000000 50% /home + udev 864088 0 864088 0% /dev +'''; + +const _lsblkWithSuccessMarker = ''' +{ + "blockdevices": [ + { + "fstype": "ext4", + "mountpoint": "/", + "fssize": 982141468672, + "fsused": 552718364672, + "fsavail": 379457622016, + "fsuse%": "56%", + "path": "/dev/sda1" + } + ] +} +LSBLK_SUCCESS +'''; + +const _malformedLsblkWithDfFallback = ''' +Filesystem 1K-blocks Used Available Use% Mounted on +udev 864088 0 864088 0% /dev +tmpfs 176724 688 176036 1% /run +/dev/vda3 40910528 18067948 20951380 47% / +tmpfs 883612 0 883612 0% /dev/shm +tmpfs 5120 0 5120 0% /run/lock +/dev/vda2 192559 11807 180752 7% /boot/efi +tmpfs 176720 104 176616 1% /run/user/1000 +''';