mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-02-23 16:45:27 +01:00
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:
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user