diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d0bdb886..160994ad 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -470,7 +470,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 403; + CURRENT_PROJECT_VERSION = 404; DEVELOPMENT_TEAM = BA88US33G6; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist"; @@ -478,7 +478,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.403; + MARKETING_VERSION = 1.0.404; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -602,7 +602,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 403; + CURRENT_PROJECT_VERSION = 404; DEVELOPMENT_TEAM = BA88US33G6; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist"; @@ -610,7 +610,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.403; + MARKETING_VERSION = 1.0.404; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -628,7 +628,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 403; + CURRENT_PROJECT_VERSION = 404; DEVELOPMENT_TEAM = BA88US33G6; ENABLE_BITCODE = NO; INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist"; @@ -636,7 +636,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.403; + MARKETING_VERSION = 1.0.404; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -657,7 +657,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 403; + CURRENT_PROJECT_VERSION = 404; DEVELOPMENT_TEAM = BA88US33G6; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -670,7 +670,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.403; + MARKETING_VERSION = 1.0.404; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget; @@ -696,7 +696,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 403; + CURRENT_PROJECT_VERSION = 404; DEVELOPMENT_TEAM = BA88US33G6; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -709,7 +709,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.403; + MARKETING_VERSION = 1.0.404; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -732,7 +732,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 403; + CURRENT_PROJECT_VERSION = 404; DEVELOPMENT_TEAM = BA88US33G6; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; @@ -745,7 +745,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.403; + MARKETING_VERSION = 1.0.404; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox.StatusWidget; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/lib/data/model/app/error.dart b/lib/data/model/app/error.dart index 11b23555..06886ab1 100644 --- a/lib/data/model/app/error.dart +++ b/lib/data/model/app/error.dart @@ -36,7 +36,8 @@ enum DockerErrType { noClient, notInstalled, invalidVersion, - cmdNoPrefix + cmdNoPrefix, + segmentsNotMatch, } class DockerErr extends Err { diff --git a/lib/data/model/app/shell_func.dart b/lib/data/model/app/shell_func.dart index 545ac20b..c67a5543 100644 --- a/lib/data/model/app/shell_func.dart +++ b/lib/data/model/app/shell_func.dart @@ -1,9 +1,13 @@ +import '../../res/server_cmd.dart'; + class AppShellFunc { final String name; final String cmd; final String flag; const AppShellFunc(this.name, this.cmd, this.flag); + + String get exec => 'sh $shellPath -$flag'; } typedef AppShellFuncs = List; @@ -39,3 +43,8 @@ esac return sb.toString(); } } + +// enum AppShellFuncType { +// status, +// docker; +// } diff --git a/lib/data/model/docker/ps.dart b/lib/data/model/docker/ps.dart index 612f7af3..ad5c910b 100644 --- a/lib/data/model/docker/ps.dart +++ b/lib/data/model/docker/ps.dart @@ -8,6 +8,10 @@ class DockerPsItem { late String status; late String ports; late String name; + String? cpu; + String? mem; + String? net; + String? disk; DockerPsItem( this.containerId, @@ -37,6 +41,20 @@ class DockerPsItem { } } + void parseStats(String rawString) { + if (rawString.isEmpty) { + return; + } + final parts = rawString.split(_seperator); + if (parts.length != 8) { + return; + } + cpu = parts[2]; + mem = parts[3]; + net = parts[5]; + disk = parts[6]; + } + bool get running => status.contains('Up '); @override diff --git a/lib/data/provider/docker.dart b/lib/data/provider/docker.dart index 0e161884..999060e1 100644 --- a/lib/data/provider/docker.dart +++ b/lib/data/provider/docker.dart @@ -5,11 +5,11 @@ import 'package:dartssh2/dartssh2.dart'; import 'package:logging/logging.dart'; import 'package:toolbox/core/extension/ssh_client.dart'; import 'package:toolbox/core/extension/stringx.dart'; -import 'package:toolbox/core/extension/uint8list.dart'; import 'package:toolbox/core/provider_base.dart'; import 'package:toolbox/data/model/docker/image.dart'; import 'package:toolbox/data/model/docker/ps.dart'; import 'package:toolbox/data/model/app/error.dart'; +import 'package:toolbox/data/res/server_cmd.dart'; import 'package:toolbox/data/store/docker.dart'; import 'package:toolbox/locator.dart'; @@ -18,9 +18,6 @@ final _versionReg = RegExp(r'(Version:)\s+([0-9]+\.[0-9]+\.[0-9]+)'); final _editionReg = RegExp(r'(Client:)\s+(.+-.+)'); final _dockerPrefixReg = RegExp(r'(sudo )?docker '); -const _dockerPS = 'docker ps -a'; -const _dockerImgs = 'docker images'; - final _logger = Logger('DOCKER'); class DockerProvider extends BusyProvider { @@ -54,49 +51,59 @@ class DockerProvider extends BusyProvider { Future refresh() async { if (isBusy) return; - final verRaw = await client!.run('docker version'.withLangExport).string; - if (verRaw.contains(_dockerNotFound)) { + setBusyState(); + + var raw = ''; + await client!.exec( + shellFuncDocker.exec, + onStderr: _onPwd, + onStdout: (data, _) => raw = '$raw$data', + ); + + if (raw.contains(_dockerNotFound)) { error = DockerErr(type: DockerErrType.notInstalled); setBusyState(false); return; } + // Check result segments count + final segments = raw.split(seperator); + if (segments.length != dockerCmds.length) { + error = DockerErr(type: DockerErrType.segmentsNotMatch); + setBusyState(false); + return; + } + + // Parse docker version + final verRaw = segments[0]; try { version = _versionReg.firstMatch(verRaw)?.group(2); edition = _editionReg.firstMatch(verRaw)?.group(2); } catch (e) { + error = DockerErr(type: DockerErrType.unknown, message: e.toString()); rethrow; } + // Parse docker ps + final psRaw = segments[1]; try { - setBusyState(); - final cmd = _wrap(_dockerPS); - - // run docker ps - var raw = ''; - await client!.exec( - cmd, - onStderr: _onPwd, - onStdout: (data, _) => raw = '$raw$data', - ); - - // parse result - final lines = raw.split('\n'); - lines.removeAt(0); + final lines = psRaw.split('\n'); lines.removeWhere((element) => element.isEmpty); + lines.removeAt(0); items = lines.map((e) => DockerPsItem.fromRawString(e)).toList(); + } catch (e) { + error = DockerErr(type: DockerErrType.unknown, message: e.toString()); + rethrow; + } finally { + setBusyState(false); + } - final imageCmd = _wrap(_dockerImgs); - raw = ''; - await client!.exec( - imageCmd, - onStderr: _onPwd, - onStdout: (data, _) => raw = '$raw$data', - ); - - final imageLines = raw.split('\n'); - imageLines.removeAt(0); + // Parse docker images + final imageRaw = segments[3]; + try { + final imageLines = imageRaw.split('\n'); imageLines.removeWhere((element) => element.isEmpty); + imageLines.removeAt(0); images = imageLines.map((e) => DockerImage.fromRawStr(e)).toList(); } catch (e) { error = DockerErr(type: DockerErrType.unknown, message: e.toString()); @@ -104,6 +111,25 @@ class DockerProvider extends BusyProvider { } finally { setBusyState(false); } + + // Parse docker stats + final statsRaw = segments[2]; + try { + final statsLines = statsRaw.split('\n'); + statsLines.removeWhere((element) => element.isEmpty); + statsLines.removeAt(0); + for (var item in items!) { + final statsLine = statsLines.firstWhere( + (element) => element.contains(item.containerId), + orElse: () => '', + ); + item.parseStats(statsLine); + } + } catch (e) { + error = DockerErr(type: DockerErrType.unknown, message: e.toString()); + } finally { + setBusyState(false); + } } Future _onPwd(String event, StreamSink stdin) async { @@ -163,11 +189,11 @@ class DockerProvider extends BusyProvider { return null; } - // judge whether to use DOCKER_HOST / sudo + // judge whether to use DOCKER_HOST String _wrap(String cmd) { final dockerHost = dockerStore.getDockerHost(hostId!); if (dockerHost == null || dockerHost.isEmpty) { - return 'sudo $cmd'.withLangExport; + return cmd.withLangExport; } return 'export DOCKER_HOST=$dockerHost && $cmd'.withLangExport; } diff --git a/lib/data/provider/server.dart b/lib/data/provider/server.dart index 589e41d8..62a2470c 100644 --- a/lib/data/provider/server.dart +++ b/lib/data/provider/server.dart @@ -245,8 +245,7 @@ class ServerProvider extends BusyProvider { if (s.client == null) return; // run script to get server status - raw = - await s.client!.run("sh $shellPath -${shellFuncStatus.flag}").string; + raw = await s.client!.run(shellFuncStatus.exec).string; segments = raw.split(seperator).map((e) => e.trim()).toList(); if (raw.isEmpty || segments.length != CmdType.values.length) { s.state = ServerState.failed; diff --git a/lib/data/res/build_data.dart b/lib/data/res/build_data.dart index 4600ad8d..00f1ef6f 100644 --- a/lib/data/res/build_data.dart +++ b/lib/data/res/build_data.dart @@ -2,8 +2,8 @@ class BuildData { static const String name = "ServerBox"; - static const int build = 403; + static const int build = 404; static const String engine = "3.10.6"; - static const String buildAt = "2023-08-02 19:59:01.356049"; + static const String buildAt = "2023-08-02 20:14:57.880607"; static const int modifications = 4; } diff --git a/lib/data/res/server_cmd.dart b/lib/data/res/server_cmd.dart index 69933f05..35c41084 100644 --- a/lib/data/res/server_cmd.dart +++ b/lib/data/res/server_cmd.dart @@ -8,7 +8,6 @@ const shellPath = '$serverBoxDir/mobile_app.sh'; const echoPWD = 'echo \$PWD'; enum CmdType { - export, net, sys, cpu, @@ -23,7 +22,6 @@ enum CmdType { } const _cmdList = [ - 'export LANG=en_US.utf-8', 'cat /proc/net/dev && date +%s', 'cat /etc/os-release | grep PRETTY_NAME', 'cat /proc/stat | grep cpu', @@ -43,30 +41,32 @@ final shellFuncStatus = AppShellFunc( 's', ); -// Check if `htop` is installed. -// Then app open SSH term and use `htop` or `ps` to see process. -const shellFuncProcess = AppShellFunc( - 'process', - ''' -if command -v htop &> /dev/null -then - htop -else - top -fi -''', - 'p', +const dockerCmds = [ + 'docker version', + 'docker ps -a', + 'docker stats --no-stream', + 'docker image ls', +]; + +final shellFuncDocker = AppShellFunc( + // `dockeR` -> avoid conflict with `docker` command + // 以防止循环递归 + 'dockeR', + dockerCmds.join('\necho $seperator\n'), + 'd', ); final _generated = [ shellFuncStatus, - shellFuncProcess, + shellFuncDocker, ].generate; final shellCmd = """ # Script for app `${BuildData.name} v1.0.${BuildData.build}` # Delete this file while app is running will cause app crash +export LANG=en_US.utf-8 + $_generated """; diff --git a/lib/view/page/docker.dart b/lib/view/page/docker.dart index 7505396b..20378224 100644 --- a/lib/view/page/docker.dart +++ b/lib/view/page/docker.dart @@ -4,6 +4,7 @@ import 'package:nil/nil.dart'; import 'package:provider/provider.dart'; import 'package:toolbox/core/extension/navigator.dart'; import 'package:toolbox/core/route.dart'; +import 'package:toolbox/data/model/docker/image.dart'; import 'package:toolbox/view/page/ssh/term.dart'; import 'package:toolbox/view/widget/input_field.dart'; @@ -69,13 +70,13 @@ class _DockerManagePageState extends State { title: TwoLineText(up: 'Docker', down: widget.spi.name), actions: [ IconButton( - onPressed: () => _docker.refresh(), + onPressed: _docker.refresh, icon: const Icon(Icons.refresh), ) ], ), body: _buildMain(), - floatingActionButton: _buildFAB(), + floatingActionButton: _docker.error == null ? _buildFAB() : null, ); }); } @@ -177,6 +178,7 @@ class _DockerManagePageState extends State { } void onSubmitted() { + context.pop(); if (_textController.text == '') { showRoundDialog( context: context, @@ -191,7 +193,6 @@ class _DockerManagePageState extends State { ); return; } - context.pop(); } Future onPwdRequest() async { @@ -215,7 +216,7 @@ class _DockerManagePageState extends State { child: Text(_s.cancel), ), TextButton( - onPressed: () => onSubmitted(), + onPressed: onSubmitted, child: Text( _s.ok, style: const TextStyle(color: Colors.red), @@ -249,7 +250,11 @@ class _DockerManagePageState extends State { ); } if (_docker.items == null || _docker.images == null) { - _docker.refresh(); + Future.delayed(const Duration(milliseconds: 177), () { + if (mounted) { + _docker.refresh(); + } + }); return centerLoading; } @@ -260,7 +265,7 @@ class _DockerManagePageState extends State { _docker.edition ?? _s.unknown, _docker.version ?? _s.unknown, ), - _buildPsItems(), + ..._buildPsItems(), _buildImages(), _buildEditHost(), ].map((e) => RoundRectCard(e))); @@ -275,50 +280,7 @@ class _DockerManagePageState extends State { if (_docker.images == null) { return nil; } - final items = _docker.images! - .map( - (e) => ListTile( - title: Text(e.repo), - subtitle: Text('${e.tag} - ${e.size}', style: grey), - trailing: IconButton( - padding: EdgeInsets.zero, - alignment: Alignment.centerRight, - icon: const Icon(Icons.delete), - onPressed: () async { - showRoundDialog( - context: context, - title: Text(_s.attention), - child: Text(_s.sureDelete(e.repo)), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text(_s.cancel), - ), - TextButton( - onPressed: () async { - context.pop(); - final result = await _docker.run( - 'docker rmi ${e.id} -f', - ); - if (result != null) { - showSnackBar( - context, - Text(result.message ?? _s.unknownError), - ); - } - }, - child: Text( - _s.ok, - style: const TextStyle(color: Colors.red), - ), - ), - ], - ); - }, - ), - ), - ) - .toList(); + final items = _docker.images!.map(_buildImageItem).toList(); items.insert( 0, ListTile( @@ -332,6 +294,51 @@ class _DockerManagePageState extends State { return Column(children: items); } + Widget _buildImageItem(DockerImage e) { + return ListTile( + title: Text(e.repo), + subtitle: Text('${e.tag} - ${e.size}', style: grey), + trailing: IconButton( + padding: EdgeInsets.zero, + alignment: Alignment.centerRight, + icon: const Icon(Icons.delete), + onPressed: () => _showImageRmDialog(e), + ), + ); + } + + void _showImageRmDialog(DockerImage e) { + showRoundDialog( + context: context, + title: Text(_s.attention), + child: Text(_s.sureDelete(e.repo)), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text(_s.cancel), + ), + TextButton( + onPressed: () async { + context.pop(); + final result = await _docker.run( + 'docker rmi ${e.id} -f', + ); + if (result != null) { + showSnackBar( + context, + Text(result.message ?? _s.unknownError), + ); + } + }, + child: Text( + _s.ok, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ); + } + Widget _buildLoading() { if (!_docker.isBusy) return nil; return Padding( @@ -440,20 +447,8 @@ class _DockerManagePageState extends State { ); } - Widget _buildPsItems() { - final items = _docker.items!.map( - (item) { - return ListTile( - title: Text(item.name), - isThreeLine: true, - subtitle: Text( - '${item.image}\n${item.status}', - style: textSize13Grey, - ), - trailing: _buildMoreBtn(item, _docker.isBusy), - ); - }, - ).toList(); + List _buildPsItems() { + final items = _docker.items!.map(_buildPsItem).toList(); items.insert( 0, ListTile( @@ -461,8 +456,39 @@ class _DockerManagePageState extends State { subtitle: Text(_buildSubtitle(_docker.items!), style: grey), ), ); + return items; + } + + Widget _buildPsItem(DockerPsItem item) { return Column( - children: items, + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(item.name), + subtitle: Text( + '${item.image}\n${item.status}', + style: textSize13Grey, + ), + trailing: _buildMoreBtn(item, _docker.isBusy), + ), + _buildPsItemStat(item), + ], + ); + } + + Widget _buildPsItemStat(DockerPsItem item) { + if (!item.running) return const SizedBox(); + return Padding( + padding: const EdgeInsets.only(left: 17, bottom: 11, right: 17), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.cpu ?? _s.unknown, style: grey), + Text(item.mem ?? _s.unknown, style: grey), + Text(item.net ?? _s.unknown, style: grey), + Text(item.disk ?? _s.unknown, style: grey), + ], + ), ); } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index aec91ce7..55c04b8c 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -475,9 +475,9 @@ baseConfigurationReference = C1C758C41C4E208965A68933 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 403; + CURRENT_PROJECT_VERSION = 404; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0.403; + MARKETING_VERSION = 1.0.404; PRODUCT_BUNDLE_IDENTIFIER = tech.lolli.serverBox.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -490,9 +490,9 @@ baseConfigurationReference = 15AF97DF993E8968098D6EBE /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 403; + CURRENT_PROJECT_VERSION = 404; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0.403; + MARKETING_VERSION = 1.0.404; PRODUCT_BUNDLE_IDENTIFIER = tech.lolli.serverBox.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -505,9 +505,9 @@ baseConfigurationReference = 7CFA7DE7FABA75685DFB6948 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 403; + CURRENT_PROJECT_VERSION = 404; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0.403; + MARKETING_VERSION = 1.0.404; PRODUCT_BUNDLE_IDENTIFIER = tech.lolli.serverBox.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0;