feat: Windows compatibility (#836)

* feat: win compatibility

* fix

* fix: uptime parse

* opt.: linux uptime accuracy

* fix: windows temperature fetching

* opt.

* opt.: powershell exec

* refactor: address PR review feedback and improve code quality

### Major Improvements:
- **Refactored Windows status parsing**: Broke down large `_getWindowsStatus` method into 13 smaller, focused helper methods for better maintainability and readability
- **Extracted system detection logic**: Created dedicated `SystemDetector` helper class to separate OS detection concerns from ServerProvider
- **Improved concurrency handling**: Implemented proper synchronization for server updates using Future-based locks to prevent race conditions

### Bug Fixes:
- **Fixed CPU percentage parsing**: Removed incorrect '*100' multiplication in BSD CPU parsing (values were already percentages)
- **Enhanced memory parsing**: Added validation and error handling to BSD memory fallback parsing with proper logging
- **Improved uptime parsing**: Added support for multiple Windows date formats and robust error handling with validation
- **Fixed division by zero**: Added safety checks in Swap.usedPercent getter

### Code Quality Enhancements:
- **Added comprehensive documentation**: Documented Windows CPU counter limitations and approach
- **Strengthened error handling**: Added detailed logging and validation throughout parsing methods
- **Improved robustness**: Enhanced BSD CPU parsing with percentage validation and warnings
- **Better separation of concerns**: Each parsing method now has single responsibility

### Files Changed:
- `lib/data/helper/system_detector.dart` (new): System detection helper
- `lib/data/model/server/cpu.dart`: Fixed percentage parsing and added validation
- `lib/data/model/server/memory.dart`: Enhanced fallback parsing and division-by-zero protection
- `lib/data/model/server/server_status_update_req.dart`: Refactored into 13 focused parsing methods
- `lib/data/provider/server.dart`: Improved synchronization and extracted system detection

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: parse & shell fn struct

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
lollipopkit🏳️‍⚧️
2025-08-08 16:56:36 +08:00
committed by GitHub
parent 46a12bc844
commit 3a615449e3
103 changed files with 9591 additions and 1906 deletions

View File

@@ -7,9 +7,7 @@ part 'app.freezed.dart';
@freezed
abstract class AppState with _$AppState {
const factory AppState({
@Default(false) bool desktopMode,
}) = _AppState;
const factory AppState({@Default(false) bool desktopMode}) = _AppState;
}
@Riverpod(keepAlive: true)

View File

@@ -12,8 +12,7 @@ import 'package:server_box/data/model/container/ps.dart';
import 'package:server_box/data/model/container/type.dart';
import 'package:server_box/data/res/store.dart';
final _dockerNotFound =
RegExp(r"command not found|Unknown command|Command '\w+' not found");
final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found");
class ContainerProvider extends ChangeNotifier {
final SSHClient? client;
@@ -90,11 +89,7 @@ class ContainerProvider extends ChangeNotifier {
final includeStats = Stores.setting.containerParseStat.fetch();
var raw = '';
final cmd = _wrap(ContainerCmdType.execAll(
type,
sudo: sudo,
includeStats: includeStats,
));
final cmd = _wrap(ContainerCmdType.execAll(type, sudo: sudo, includeStats: includeStats));
final code = await client?.execWithPwd(
cmd,
context: context,
@@ -130,10 +125,7 @@ class ContainerProvider extends ChangeNotifier {
try {
version = json.decode(verRaw)['Client']['Version'];
} catch (e, trace) {
error = ContainerErr(
type: ContainerErrType.invalidVersion,
message: '$e',
);
error = ContainerErr(type: ContainerErrType.invalidVersion, message: '$e');
Loggers.app.warning('Container version failed', e, trace);
} finally {
notifyListeners();
@@ -150,10 +142,7 @@ class ContainerProvider extends ChangeNotifier {
lines.removeWhere((element) => element.isEmpty);
items = lines.map((e) => ContainerPs.fromRaw(e, type)).toList();
} catch (e, trace) {
error = ContainerErr(
type: ContainerErrType.parsePs,
message: '$e',
);
error = ContainerErr(type: ContainerErrType.parsePs, message: '$e');
Loggers.app.warning('Container ps failed', e, trace);
} finally {
notifyListeners();
@@ -173,10 +162,7 @@ class ContainerProvider extends ChangeNotifier {
images = lines.map((e) => ContainerImg.fromRawJson(e, type)).toList();
}
} catch (e, trace) {
error = ContainerErr(
type: ContainerErrType.parseImages,
message: '$e',
);
error = ContainerErr(type: ContainerErrType.parseImages, message: '$e');
Loggers.app.warning('Container images failed', e, trace);
} finally {
notifyListeners();
@@ -199,10 +185,7 @@ class ContainerProvider extends ChangeNotifier {
item.parseStats(statsLine);
}
} catch (e, trace) {
error = ContainerErr(
type: ContainerErrType.parseStats,
message: '$e',
);
error = ContainerErr(type: ContainerErrType.parseStats, message: '$e');
Loggers.app.warning('Parse docker stats: $statsRaw', e, trace);
} finally {
notifyListeners();
@@ -261,10 +244,7 @@ class ContainerProvider extends ChangeNotifier {
notifyListeners();
if (code != 0) {
return ContainerErr(
type: ContainerErrType.unknown,
message: errs.join('\n').trim(),
);
return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim());
}
if (autoRefresh) await refresh();
return null;
@@ -288,40 +268,32 @@ enum ContainerCmdType {
version,
ps,
stats,
images,
images
// No specific commands needed for prune actions as they are simple
// and don't require splitting output with ShellFunc.seperator
;
String exec(
ContainerType type, {
bool sudo = false,
bool includeStats = false,
}) {
String exec(ContainerType type, {bool sudo = false, bool includeStats = false}) {
final prefix = sudo ? 'sudo -S ${type.name}' : type.name;
return switch (this) {
ContainerCmdType.version => '$prefix version $_jsonFmt',
ContainerCmdType.ps => switch (type) {
/// TODO: Rollback to json format when permformance recovers.
/// Use [_jsonFmt] in Docker will cause the operation to slow down.
ContainerType.docker => '$prefix ps -a --format "table {{printf \\"'
/// TODO: Rollback to json format when permformance recovers.
/// Use [_jsonFmt] in Docker will cause the operation to slow down.
ContainerType.docker =>
'$prefix ps -a --format "table {{printf \\"'
'%-15.15s '
'%-30.30s '
'${"%-50.50s " * 2}\\"'
' .ID .Status .Names .Image}}"',
ContainerType.podman => '$prefix ps -a $_jsonFmt',
},
ContainerCmdType.stats =>
includeStats ? '$prefix stats --no-stream $_jsonFmt' : 'echo PASS',
ContainerType.podman => '$prefix ps -a $_jsonFmt',
},
ContainerCmdType.stats => includeStats ? '$prefix stats --no-stream $_jsonFmt' : 'echo PASS',
ContainerCmdType.images => '$prefix image ls $_jsonFmt',
};
}
static String execAll(
ContainerType type, {
bool sudo = false,
bool includeStats = false,
}) {
static String execAll(ContainerType type, {bool sudo = false, bool includeStats = false}) {
return ContainerCmdType.values
.map((e) => e.exec(type, sudo: sudo, includeStats: includeStats))
.join('\necho ${ShellFunc.seperator}\n');

View File

@@ -86,15 +86,18 @@ final class PveProvider extends ChangeNotifier {
forward.stream.cast<List<int>>().pipe(socket);
socket.cast<List<int>>().pipe(forward.sink);
});
final newUrl = Uri.parse(addr)
.replace(host: 'localhost', port: _localPort)
.toString();
final newUrl = Uri.parse(
addr,
).replace(host: 'localhost', port: _localPort).toString();
debugPrint('Forwarding $newUrl to $addr');
}
}
Future<ConnectionTask<Socket>> cf(
Uri url, String? proxyHost, int? proxyPort) async {
Uri url,
String? proxyHost,
int? proxyPort,
) async {
/* final serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final _localPort = serverSocket.port;
serverSocket.listen((socket) async {
@@ -105,8 +108,11 @@ final class PveProvider extends ChangeNotifier {
});*/
if (url.isScheme('https')) {
return SecureSocket.startConnect('localhost', _localPort,
onBadCertificate: (_) => true);
return SecureSocket.startConnect(
'localhost',
_localPort,
onBadCertificate: (_) => true,
);
} else {
return Socket.startConnect('localhost', _localPort);
}
@@ -119,7 +125,7 @@ final class PveProvider extends ChangeNotifier {
'username': spi.user,
'password': spi.pwd,
'realm': 'pam',
'new-format': '1'
'new-format': '1',
},
options: Options(
headers: {HttpHeaders.contentTypeHeader: Headers.jsonContentType},
@@ -151,8 +157,10 @@ final class PveProvider extends ChangeNotifier {
try {
final resp = await session.get('$addr/api2/json/cluster/resources');
final res = resp.data['data'] as List;
final result =
await Computer.shared.start(PveRes.parse, (res, data.value));
final result = await Computer.shared.start(PveRes.parse, (
res,
data.value,
));
data.value = result;
} catch (e) {
Loggers.app.warning('PVE list failed', e);
@@ -164,29 +172,33 @@ final class PveProvider extends ChangeNotifier {
Future<bool> reboot(String node, String id) async {
await connected.future;
final resp =
await session.post('$addr/api2/json/nodes/$node/$id/status/reboot');
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/reboot',
);
return _isCtrlSuc(resp);
}
Future<bool> start(String node, String id) async {
await connected.future;
final resp =
await session.post('$addr/api2/json/nodes/$node/$id/status/start');
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/start',
);
return _isCtrlSuc(resp);
}
Future<bool> stop(String node, String id) async {
await connected.future;
final resp =
await session.post('$addr/api2/json/nodes/$node/$id/status/stop');
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/stop',
);
return _isCtrlSuc(resp);
}
Future<bool> shutdown(String node, String id) async {
await connected.future;
final resp =
await session.post('$addr/api2/json/nodes/$node/$id/status/shutdown');
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/shutdown',
);
return _isCtrlSuc(resp);
}

View File

@@ -9,6 +9,7 @@ import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/core/utils/ssh_auth.dart';
import 'package:server_box/data/helper/system_detector.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/shell_func.dart';
import 'package:server_box/data/model/server/server.dart';
@@ -32,6 +33,8 @@ class ServerProvider extends Provider {
static final _manualDisconnectedIds = <String>{};
static final _serverIdsUpdating = <String, Future<void>?>{};
@override
Future<void> load() async {
super.load();
@@ -124,11 +127,35 @@ class ServerProvider extends Provider {
return;
}
return await _getData(s.spi);
// Check if already updating, and if so, wait for it to complete
final existingUpdate = _serverIdsUpdating[s.spi.id];
if (existingUpdate != null) {
// Already updating, wait for the existing update to complete
try {
await existingUpdate;
} catch (e) {
// Ignore errors from the existing update, we'll try our own
}
return;
}
// Start a new update operation
final updateFuture = _updateServer(s.spi);
_serverIdsUpdating[s.spi.id] = updateFuture;
try {
await updateFuture;
} finally {
_serverIdsUpdating.remove(s.spi.id);
}
}),
);
}
static Future<void> _updateServer(Spi spi) async {
await _getData(spi);
}
static Future<void> startAutoRefresh() async {
var duration = Stores.setting.serverStatusUpdateInterval.fetch();
stopAutoRefresh();
@@ -305,13 +332,17 @@ class ServerProvider extends Provider {
_setServerState(s, ServerConn.connected);
try {
// Detect system type using helper
final detectedSystemType = await SystemDetector.detect(sv.client!, spi);
sv.status.system = detectedSystemType;
final (_, writeScriptResult) = await sv.client!.exec((session) async {
final scriptRaw = ShellFunc.allScript(spi.custom?.cmds).uint8List;
final scriptRaw = ShellFunc.allScript(spi.custom?.cmds, systemType: detectedSystemType).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
}, entry: ShellFunc.getInstallShellCmd(spi.id));
if (writeScriptResult.isNotEmpty) {
ShellFunc.switchScriptDir(spi.id);
}, entry: ShellFunc.getInstallShellCmd(spi.id, systemType: detectedSystemType));
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
ShellFunc.switchScriptDir(spi.id, systemType: detectedSystemType);
throw writeScriptResult;
}
} on SSHAuthAbortError catch (e) {
@@ -351,7 +382,8 @@ class ServerProvider extends Provider {
String? raw;
try {
raw = await sv.client?.run(ShellFunc.status.exec(spi.id)).string;
raw = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)).string;
dprint('Get status from ${spi.name}:\n$raw');
segments = raw?.split(ShellFunc.seperator).map((e) => e.trim()).toList();
if (raw == null || raw.isEmpty || segments == null || segments.isEmpty) {
if (Stores.setting.keepStatusWhenErr.fetch()) {

View File

@@ -44,10 +44,8 @@ final class SystemdProvider {
}
}
final parsedUserUnits =
await _parseUnitObj(userUnits, SystemdUnitScope.user);
final parsedSystemUnits =
await _parseUnitObj(systemUnits, SystemdUnitScope.system);
final parsedUserUnits = await _parseUnitObj(userUnits, SystemdUnitScope.user);
final parsedSystemUnits = await _parseUnitObj(systemUnits, SystemdUnitScope.system);
this.units.value = [...parsedUserUnits, ...parsedSystemUnits];
} catch (e, s) {
Loggers.app.warning('Parse systemd', e, s);
@@ -56,14 +54,10 @@ final class SystemdProvider {
isBusy.value = false;
}
Future<List<SystemdUnit>> _parseUnitObj(
List<String> unitNames,
SystemdUnitScope scope,
) async {
final unitNames_ = unitNames
.map((e) => e.trim().split('/').last.split('.').first)
.toList();
final script = '''
Future<List<SystemdUnit>> _parseUnitObj(List<String> unitNames, SystemdUnitScope scope) async {
final unitNames_ = unitNames.map((e) => e.trim().split('/').last.split('.').first).toList();
final script =
'''
for unit in ${unitNames_.join(' ')}; do
state=\$(systemctl show --no-pager \$unit)
echo -n "${ShellFunc.seperator}\n\$state"
@@ -108,13 +102,9 @@ done
continue;
}
parsedUnits.add(SystemdUnit(
name: name,
type: unitType,
scope: scope,
state: unitState,
description: description,
));
parsedUnits.add(
SystemdUnit(name: name, type: unitType, scope: scope, state: unitState, description: description),
);
}
parsedUnits.sort((a, b) {
@@ -131,7 +121,8 @@ done
return parsedUnits;
}
late final _getUnitsCmd = '''
late final _getUnitsCmd =
'''
get_files() {
unit_type=\$1
base_dir=\$2