diff --git a/lib/features/desktop/desktop_view.dart b/lib/features/desktop/desktop_view.dart index 8bdd5272..9b39d3db 100644 --- a/lib/features/desktop/desktop_view.dart +++ b/lib/features/desktop/desktop_view.dart @@ -8,6 +8,8 @@ import '../../app/app_controller.dart'; import '../../runtime/gateway_acp_client.dart'; import '../../widgets/surface_card.dart'; import '../../i18n/app_language.dart'; +import '../workspace_management/workspace_management_panel.dart'; +import '../workspace_management/workspace_management_i18n.dart'; class DesktopView extends StatefulWidget { const DesktopView({ @@ -369,6 +371,15 @@ class _DesktopViewState extends State { ), label: const Text('高级选项'), ), + OutlinedButton.icon( + key: const Key('desktop-workspace-management-button'), + onPressed: () => WorkspaceManagementPanel.show( + context, + widget.controller, + ), + icon: const Icon(Icons.dns_outlined), + label: Text(WorkspaceManagementText.button), + ), // Maximize Toggle if (widget.onToggleMaximize != null) IconButton( diff --git a/lib/features/workspace_management/playbook_runner.dart b/lib/features/workspace_management/playbook_runner.dart new file mode 100644 index 00000000..31154558 --- /dev/null +++ b/lib/features/workspace_management/playbook_runner.dart @@ -0,0 +1,274 @@ +import 'dart:async'; + +import '../../i18n/app_language.dart'; +import 'server_detector.dart'; +import 'ssh_executor.dart'; +import 'workspace_provision_models.dart'; + +class PlaybookRunner { + const PlaybookRunner(this.executor); + + static const String playbookRepoUrl = 'https://github.com/x-evor/playbooks.git'; + static const String createPlaybook = 'setup-ai-workspace-all-in-one.yml'; + static const String upgradePlaybook = 'upgrade-ai-workspace.yml'; + + final WorkspaceSshExecutor executor; + + Future run({ + required SshConfig ssh, + required String action, + required String workspaceDomain, + required String bridgeToken, + required String installPath, + required bool installMissingPrerequisites, + required ServerInfo? serverInfo, + required void Function(String stepId, StepStatus status, String? message) + onStepUpdate, + required void Function(String logLine) onLog, + }) async { + if (action == 'upgrade') { + throw PlaybookRunException( + appText( + 'playbooks 仓库尚未提供 $upgradePlaybook。', + 'The playbooks repository does not provide $upgradePlaybook yet.', + ), + ); + } + + var info = serverInfo; + if (info == null) { + onStepUpdate('ssh_connect', StepStatus.running, null); + info = await ServerDetector(executor).detect(ssh, workspaceDomain); + onStepUpdate('ssh_connect', StepStatus.success, null); + onStepUpdate('detect_env', StepStatus.success, info.displaySummary); + } + + if (info.hasMissingPrerequisites) { + if (!installMissingPrerequisites) { + throw PlaybookRunException( + appText( + '目标服务器缺少 git 或 ansible。', + 'The target server is missing git or ansible.', + ), + ); + } + onStepUpdate('install_deps', StepStatus.running, appText('安装 git/ansible', 'Installing git/ansible')); + await _executeChecked(ssh, _preflightInstallCommand(ssh), onLog); + onStepUpdate('install_deps', StepStatus.success, appText('基础依赖已安装', 'Base dependencies installed')); + } + + onStepUpdate('install_deps', StepStatus.running, appText('拉取 playbooks', 'Fetching playbooks')); + await _executeChecked(ssh, _cloneOrPullCommand(installPath), onLog); + + final inventoryPath = '/tmp/xworkspace-inventory.ini'; + final varsPath = '/tmp/xworkspace-vars.yml'; + await _executeChecked( + ssh, + _writeInventoryAndVarsCommand( + inventoryPath: inventoryPath, + varsPath: varsPath, + workspaceDomain: workspaceDomain, + bridgeToken: bridgeToken, + ), + onLog, + ); + + await _runAnsible( + ssh: ssh, + command: _ansibleCommand( + installPath: installPath, + inventoryPath: inventoryPath, + varsPath: varsPath, + ), + onStepUpdate: onStepUpdate, + onLog: onLog, + ); + } + + Future _executeChecked( + SshConfig ssh, + String command, + void Function(String logLine) onLog, + ) async { + final result = await executor.execute(ssh, command); + for (final line in result.combinedOutput.split(RegExp(r'\r?\n'))) { + if (line.trim().isNotEmpty) { + onLog(line); + } + } + if (!result.success) { + throw PlaybookRunException(result.combinedOutput.trim()); + } + } + + Future _runAnsible({ + required SshConfig ssh, + required String command, + required void Function(String stepId, StepStatus status, String? message) + onStepUpdate, + required void Function(String logLine) onLog, + }) async { + final parser = AnsibleOutputParser(); + var failed = false; + await for (final chunk in executor.executeStreaming(ssh, command)) { + for (final raw in chunk.split(RegExp(r'\r?\n'))) { + final line = raw.trimRight(); + if (line.isEmpty) { + continue; + } + onLog(line); + final event = parser.parseLine(line); + if (event != null) { + onStepUpdate(event.stepId, event.status, event.message); + failed = failed || event.status == StepStatus.failed; + } + if (line.startsWith('REMOTE_EXIT_CODE=')) { + failed = true; + } + } + } + if (failed) { + throw PlaybookRunException(appText('Playbook 执行失败。', 'Playbook execution failed.')); + } + for (final id in [ + 'install_deps', + 'deploy_webrtc', + 'deploy_bridge', + 'config_caddy', + 'config_gateway', + 'start_services', + ]) { + onStepUpdate(id, StepStatus.success, null); + } + } + + static String _preflightInstallCommand(SshConfig ssh) { + final apt = 'DEBIAN_FRONTEND=noninteractive apt-get update && ' + 'DEBIAN_FRONTEND=noninteractive apt-get install -y git ansible'; + if (ssh.username == 'root') { + return apt; + } + final sudoPassword = ssh.sudoPassword?.trim(); + if (sudoPassword != null && sudoPassword.isNotEmpty) { + return "printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S sh -lc ${shellQuote(apt)}"; + } + return 'sudo -n sh -lc ${shellQuote(apt)}'; + } + + static String _cloneOrPullCommand(String installPath) { + final path = shellQuote(installPath.trim()); + final repo = shellQuote(playbookRepoUrl); + return 'mkdir -p $path && cd $path && ' + 'if [ -d .git ]; then git pull --ff-only origin main; ' + 'else git clone $repo .; fi'; + } + + static String _writeInventoryAndVarsCommand({ + required String inventoryPath, + required String varsPath, + required String workspaceDomain, + required String bridgeToken, + }) { + final domain = workspaceDomain.trim(); + final publicUrl = 'https://$domain'; + return ''' +cat > ${shellQuote(inventoryPath)} <<'EOF' +[all] +localhost ansible_connection=local +EOF +cat > ${shellQuote(varsPath)} <<'EOF' +workspace_domain: $domain +xworkmate_bridge_domain: $domain +xworkmate_bridge_public_base_url: $publicUrl +xworkmate_bridge_service_domain: $domain +xworkmate_bridge_service_public_base_url: $publicUrl +xworkmate_bridge_auth_token: ${bridgeToken.trim()} +EOF +'''; + } + + static String _ansibleCommand({ + required String installPath, + required String inventoryPath, + required String varsPath, + }) { + return 'cd ${shellQuote(installPath.trim())} && ' + 'ANSIBLE_FORCE_COLOR=0 ansible-playbook ' + '-i ${shellQuote(inventoryPath)} ' + '${shellQuote(createPlaybook)} ' + '-e @${shellQuote(varsPath)} 2>&1'; + } +} + +class AnsibleStepEvent { + const AnsibleStepEvent(this.stepId, this.status, this.message); + + final String stepId; + final StepStatus status; + final String? message; +} + +class AnsibleOutputParser { + String? _currentStepId; + String? _currentTask; + + AnsibleStepEvent? parseLine(String line) { + final taskMatch = RegExp(r'^TASK \[(.+?)\]').firstMatch(line); + if (taskMatch != null) { + _currentTask = taskMatch.group(1); + _currentStepId = stepIdForTask(_currentTask ?? ''); + return AnsibleStepEvent(_currentStepId!, StepStatus.running, _currentTask); + } + if (_currentStepId == null) { + return null; + } + final lower = line.toLowerCase(); + if (lower.startsWith('fatal:') || lower.contains(' failed=')) { + return AnsibleStepEvent(_currentStepId!, StepStatus.failed, line); + } + if (lower.startsWith('ok:') || lower.startsWith('changed:')) { + return AnsibleStepEvent(_currentStepId!, StepStatus.success, _currentTask); + } + if (lower.startsWith('skipping:')) { + return AnsibleStepEvent(_currentStepId!, StepStatus.skipped, _currentTask); + } + return null; + } + + static String stepIdForTask(String task) { + final text = task.toLowerCase(); + if (text.contains('bridge') || text.contains('acp_server')) { + return 'deploy_bridge'; + } + if (text.contains('caddy') || text.contains('tls') || text.contains('cert')) { + return 'config_caddy'; + } + if (text.contains('gateway') || text.contains('openclaw')) { + return 'config_gateway'; + } + if (text.contains('systemd') || + text.contains('service') || + text.contains('enable') || + text.contains('start') || + text.contains('restart')) { + return 'start_services'; + } + if (text.contains('xworkspace') || + text.contains('console') || + text.contains('desktop') || + text.contains('ttyd') || + text.contains('chrome')) { + return 'deploy_webrtc'; + } + return 'install_deps'; + } +} + +class PlaybookRunException implements Exception { + const PlaybookRunException(this.message); + + final String message; + + @override + String toString() => message.isEmpty ? 'Playbook failed' : message; +} diff --git a/lib/features/workspace_management/server_detector.dart b/lib/features/workspace_management/server_detector.dart new file mode 100644 index 00000000..3d3ac450 --- /dev/null +++ b/lib/features/workspace_management/server_detector.dart @@ -0,0 +1,100 @@ +import 'workspace_provision_models.dart'; +import 'ssh_executor.dart'; + +class ServerDetector { + const ServerDetector(this.executor); + + final WorkspaceSshExecutor executor; + + Future detect(SshConfig ssh, String workspaceDomain) async { + final result = await executor.execute( + ssh, + detectionCommand(workspaceDomain), + ); + if (!result.success) { + throw ServerDetectionException(result.combinedOutput.trim()); + } + return parseServerInfo(result.stdout); + } + + static String detectionCommand(String workspaceDomain) { + final domain = shellQuote(workspaceDomain.trim()); + return ''' +if command -v lsb_release >/dev/null 2>&1; then + echo "OS=\$(lsb_release -ds)" +else + . /etc/os-release 2>/dev/null || true + echo "OS=\${PRETTY_NAME:-unknown}" +fi +echo "ARCH=\$(uname -m)" +echo "SUDO=\$(sudo -n true 2>/dev/null && echo yes || echo no)" +echo "DOCKER=\$(docker --version 2>/dev/null || echo missing)" +echo "SYSTEMD=\$(systemctl --version 2>/dev/null | head -1 || echo missing)" +echo "CADDY=\$(caddy version 2>/dev/null || echo missing)" +echo "ANSIBLE=\$(ansible --version 2>/dev/null | head -1 || echo missing)" +echo "GIT=\$(git --version 2>/dev/null || echo missing)" +echo "DNS_OK=\$(getent hosts $domain 2>/dev/null | wc -l | tr -d ' ')" +echo "PORT_443_LISTENERS=\$(ss -ltn '( sport = :443 )' 2>/dev/null | tail -n +2 | wc -l | tr -d ' ')" +if command -v ufw >/dev/null 2>&1; then + UFW_STATUS="\$(ufw status 2>/dev/null || sudo -n ufw status 2>/dev/null || echo unavailable)" + if printf '%s' "\$UFW_STATUS" | grep -qi 'Status: inactive'; then + echo "PORT_443_OPEN=yes" + elif printf '%s' "\$UFW_STATUS" | grep -Eqi '(^|[[:space:]])(443(/tcp)?|https)[[:space:]]+ALLOW'; then + echo "PORT_443_OPEN=yes" + else + echo "PORT_443_OPEN=no" + fi + elif command -v firewall-cmd >/dev/null 2>&1; then + FIREWALL_STATE="\$(firewall-cmd --state 2>/dev/null || sudo -n firewall-cmd --state 2>/dev/null || echo not-running)" + if [ "\$FIREWALL_STATE" = "running" ]; then + if firewall-cmd --quiet --query-service=https 2>/dev/null || + sudo -n firewall-cmd --quiet --query-service=https 2>/dev/null || + firewall-cmd --quiet --query-port=443/tcp 2>/dev/null || + sudo -n firewall-cmd --quiet --query-port=443/tcp 2>/dev/null; then + echo "PORT_443_OPEN=yes" + else + echo "PORT_443_OPEN=no" + fi + else + echo "PORT_443_OPEN=yes" + fi +else + echo "PORT_443_OPEN=yes" +fi +'''; + } + + static ServerInfo parseServerInfo(String output) { + final values = {}; + for (final raw in output.split(RegExp(r'\r?\n'))) { + final index = raw.indexOf('='); + if (index <= 0) { + continue; + } + values[raw.substring(0, index).trim()] = raw.substring(index + 1).trim(); + } + return ServerInfo( + os: values['OS'] ?? '', + arch: values['ARCH'] ?? '', + sudoAvailable: (values['SUDO'] ?? '').toLowerCase() == 'yes', + dockerVersion: values['DOCKER'] ?? 'missing', + systemdVersion: values['SYSTEMD'] ?? 'missing', + caddyVersion: values['CADDY'] ?? 'missing', + ansibleVersion: values['ANSIBLE'] ?? 'missing', + gitVersion: values['GIT'] ?? 'missing', + dnsAddressCount: int.tryParse(values['DNS_OK'] ?? '') ?? 0, + port443ListenerCount: + int.tryParse(values['PORT_443_LISTENERS'] ?? '') ?? 0, + port443Open: (values['PORT_443_OPEN'] ?? '').toLowerCase() != 'no', + ); + } +} + +class ServerDetectionException implements Exception { + const ServerDetectionException(this.message); + + final String message; + + @override + String toString() => message.isEmpty ? 'Server detection failed' : message; +} diff --git a/lib/features/workspace_management/ssh_executor.dart b/lib/features/workspace_management/ssh_executor.dart new file mode 100644 index 00000000..8e5103ce --- /dev/null +++ b/lib/features/workspace_management/ssh_executor.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dartssh2/dartssh2.dart'; + +import 'workspace_provision_models.dart'; + +abstract class WorkspaceSshExecutor { + Future execute(SshConfig config, String command); + Stream executeStreaming(SshConfig config, String command); +} + +class DartSshExecutor implements WorkspaceSshExecutor { + const DartSshExecutor(); + + @override + Future execute(SshConfig config, String command) async { + final client = await _connect(config); + try { + final result = await client.runWithResult(command); + return SshResult( + exitCode: result.exitCode ?? -1, + stdout: utf8.decode(result.stdout, allowMalformed: true), + stderr: utf8.decode(result.stderr, allowMalformed: true), + ); + } finally { + client.close(); + await client.done.catchError((_) {}); + } + } + + @override + Stream executeStreaming(SshConfig config, String command) async* { + final client = await _connect(config); + SSHSession? session; + try { + session = await client.execute(command); + final controller = StreamController(); + final subscriptions = >>[ + session.stdout.listen( + (chunk) => controller.add(utf8.decode(chunk, allowMalformed: true)), + onError: controller.addError, + ), + session.stderr.listen( + (chunk) => controller.add(utf8.decode(chunk, allowMalformed: true)), + onError: controller.addError, + ), + ]; + unawaited( + session.done.then((_) async { + for (final subscription in subscriptions) { + await subscription.cancel(); + } + await controller.close(); + }), + ); + await for (final chunk in controller.stream) { + yield chunk; + } + if ((session.exitCode ?? 0) != 0) { + yield 'REMOTE_EXIT_CODE=${session.exitCode ?? -1}'; + } + } finally { + session?.close(); + client.close(); + await client.done.catchError((_) {}); + } + } + + Future _connect(SshConfig config) async { + final socket = await SSHSocket.connect( + config.host, + config.port, + ).timeout(config.connectTimeout); + final identities = await _identities(config); + final client = SSHClient( + socket, + username: config.username, + identities: identities.isEmpty ? null : identities, + onPasswordRequest: config.authMethod == AuthMethod.password + ? () => config.password + : null, + onVerifyHostKey: (hostKey, fingerprint) => true, + ); + await client.authenticated.timeout(config.connectTimeout); + return client; + } + + Future> _identities(SshConfig config) async { + if (config.authMethod != AuthMethod.sshKey) { + return const []; + } + final inline = config.privateKey?.trim(); + if (inline != null && inline.isNotEmpty) { + return SSHKeyPair.fromPem(inline); + } + final path = config.privateKeyPath?.trim(); + if (path != null && path.isNotEmpty) { + return SSHKeyPair.fromPem(await File(path).readAsString()); + } + return const []; + } +} + +String shellQuote(String value) { + return "'${value.replaceAll("'", "'\"'\"'")}'"; +} diff --git a/lib/features/workspace_management/workspace_management_form.dart b/lib/features/workspace_management/workspace_management_form.dart new file mode 100644 index 00000000..794e30dc --- /dev/null +++ b/lib/features/workspace_management/workspace_management_form.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; + +import '../../i18n/app_language.dart'; +import 'workspace_provision_controller.dart'; +import 'workspace_provision_models.dart'; + +class WorkspaceManagementForm extends StatefulWidget { + const WorkspaceManagementForm({ + super.key, + required this.controller, + required this.onDetect, + required this.onCreate, + }); + + final WorkspaceProvisionController controller; + final VoidCallback onDetect; + final VoidCallback onCreate; + + @override + State createState() => + _WorkspaceManagementFormState(); +} + +class _WorkspaceManagementFormState extends State { + late final TextEditingController _serverController; + late final TextEditingController _domainController; + late final TextEditingController _userController; + late final TextEditingController _passwordController; + late final TextEditingController _keyController; + late final TextEditingController _keyPathController; + late final TextEditingController _portController; + late final TextEditingController _sudoController; + late final TextEditingController _installPathController; + + @override + void initState() { + super.initState(); + final c = widget.controller; + _serverController = TextEditingController(text: c.serverAddress); + _domainController = TextEditingController(text: c.workspaceDomain); + _userController = TextEditingController(text: c.sshUsername); + _passwordController = TextEditingController(text: c.sshPassword ?? ''); + _keyController = TextEditingController(text: c.sshKeyContent ?? ''); + _keyPathController = TextEditingController(text: c.sshKeyPath ?? ''); + _portController = TextEditingController(text: c.sshPort.toString()); + _sudoController = TextEditingController(text: c.sudoPassword ?? ''); + _installPathController = TextEditingController(text: c.installPath); + } + + @override + void dispose() { + _serverController.dispose(); + _domainController.dispose(); + _userController.dispose(); + _passwordController.dispose(); + _keyController.dispose(); + _keyPathController.dispose(); + _portController.dispose(); + _sudoController.dispose(); + _installPathController.dispose(); + super.dispose(); + } + + void _sync() { + widget.controller.updateForm( + serverAddress: _serverController.text.trim(), + workspaceDomain: _domainController.text.trim(), + sshUsername: _userController.text.trim(), + sshPassword: _passwordController.text, + sshKeyContent: _keyController.text, + sshKeyPath: _keyPathController.text.trim(), + sshPort: int.tryParse(_portController.text.trim()) ?? 22, + sudoPassword: _sudoController.text, + installPath: _installPathController.text.trim().isEmpty + ? '/opt/xworkspace/playbooks' + : _installPathController.text.trim(), + ); + } + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + final disabled = controller.isBusy; + + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth > 760 ? 2 : 1; + final itemWidth = + (constraints.maxWidth - (columns - 1) * 12) / columns; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _field( + width: itemWidth, + controller: _serverController, + enabled: !disabled, + label: appText('服务器地址 *', 'Server address *'), + icon: Icons.dns_outlined, + ), + _field( + width: itemWidth, + controller: _domainController, + enabled: !disabled, + label: appText('Workspace 域名 *', 'Workspace domain *'), + icon: Icons.public_outlined, + ), + _field( + width: itemWidth, + controller: _userController, + enabled: !disabled, + label: appText('SSH 用户名 *', 'SSH username *'), + icon: Icons.person_outline, + ), + SizedBox( + width: itemWidth, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: AuthMethod.sshKey, + icon: const Icon(Icons.key_outlined), + label: Text(appText('SSH Key', 'SSH Key')), + ), + ButtonSegment( + value: AuthMethod.password, + icon: const Icon(Icons.password_outlined), + label: Text(appText('密码', 'Password')), + ), + ], + selected: {controller.authMethod}, + onSelectionChanged: disabled + ? null + : (value) => controller.updateForm( + authMethod: value.single, + ), + ), + ), + if (controller.authMethod == AuthMethod.password) + _field( + width: itemWidth, + controller: _passwordController, + enabled: !disabled, + label: appText('SSH 密码 *', 'SSH password *'), + icon: Icons.lock_outline, + obscureText: true, + ) + else ...[ + _field( + width: itemWidth, + controller: _keyPathController, + enabled: !disabled, + label: appText('SSH Key 文件路径', 'SSH key file path'), + icon: Icons.folder_outlined, + ), + _field( + width: constraints.maxWidth, + controller: _keyController, + enabled: !disabled, + label: appText('SSH Key 内容', 'SSH key content'), + icon: Icons.article_outlined, + minLines: 3, + maxLines: 5, + ), + ], + ], + ); + }, + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: disabled + ? null + : () => controller.updateForm( + showAdvanced: !controller.showAdvanced, + ), + icon: Icon( + controller.showAdvanced + ? Icons.expand_less + : Icons.expand_more, + ), + label: Text(appText('高级选项', 'Advanced options')), + ), + ), + if (controller.showAdvanced) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _field( + width: 140, + controller: _portController, + enabled: !disabled, + label: appText('SSH 端口', 'SSH port'), + icon: Icons.numbers_outlined, + keyboardType: TextInputType.number, + ), + _field( + width: 220, + controller: _sudoController, + enabled: !disabled, + label: appText('sudo 密码', 'sudo password'), + icon: Icons.admin_panel_settings_outlined, + obscureText: true, + ), + _field( + width: 320, + controller: _installPathController, + enabled: !disabled, + label: appText('安装路径', 'Install path'), + icon: Icons.storage_outlined, + ), + ], + ), + ], + const SizedBox(height: 16), + Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.tonalIcon( + key: const Key('workspace-management-detect-button'), + onPressed: disabled + ? null + : () { + _sync(); + widget.onDetect(); + }, + icon: const Icon(Icons.health_and_safety_outlined), + label: Text(appText('检测服务器', 'Detect server')), + ), + FilledButton.icon( + key: const Key('workspace-management-create-button'), + onPressed: disabled + ? null + : () { + _sync(); + widget.onCreate(); + }, + icon: const Icon(Icons.rocket_launch_outlined), + label: Text(appText('创建工作空间', 'Create workspace')), + ), + Tooltip( + message: appText( + '等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用', + 'Enabled after playbooks provides upgrade-ai-workspace.yml', + ), + child: FilledButton.tonalIcon( + key: const Key('workspace-management-upgrade-button'), + onPressed: null, + icon: const Icon(Icons.system_update_alt_outlined), + label: Text(appText('升级工作空间', 'Upgrade workspace')), + ), + ), + ], + ), + ], + ); + }, + ); + } + + Widget _field({ + required double width, + required TextEditingController controller, + required bool enabled, + required String label, + required IconData icon, + bool obscureText = false, + int minLines = 1, + int maxLines = 1, + TextInputType? keyboardType, + }) { + return SizedBox( + width: width, + child: TextField( + controller: controller, + enabled: enabled, + obscureText: obscureText, + minLines: minLines, + maxLines: obscureText ? 1 : maxLines, + keyboardType: keyboardType, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon, size: 18), + ), + ), + ); + } +} diff --git a/lib/features/workspace_management/workspace_management_i18n.dart b/lib/features/workspace_management/workspace_management_i18n.dart new file mode 100644 index 00000000..0de59f88 --- /dev/null +++ b/lib/features/workspace_management/workspace_management_i18n.dart @@ -0,0 +1,29 @@ +import '../../i18n/app_language.dart'; + +class WorkspaceManagementText { + const WorkspaceManagementText._(); + + static String get button => + appText('工作空间管理', 'Workspace management'); + static String get title => + appText('创建 / 升级 AI 工作空间', 'Create / Upgrade AI Workspace'); + static String get detect => appText('检测服务器', 'Detect server'); + static String get create => appText('创建工作空间', 'Create workspace'); + static String get upgrade => appText('升级工作空间', 'Upgrade workspace'); + static String get upgradeUnavailable => appText( + '等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用', + 'Enabled after playbooks provides upgrade-ai-workspace.yml', + ); + static String get logs => appText('查看日志', 'View logs'); + static String get copyLogs => appText('复制日志', 'Copy logs'); + static String get ready => + appText('工作空间已就绪', 'Workspace is ready'); + static String get failed => appText('执行失败', 'Provisioning failed'); + static String get connectToWorkspace => + appText('连接到该工作空间', 'Connect to this workspace'); + static String get copyAddress => appText('复制地址', 'Copy address'); + static String get requiredFields => appText( + '请填写服务器地址、Workspace 域名和认证信息。', + 'Enter server address, workspace domain, and authentication.', + ); +} diff --git a/lib/features/workspace_management/workspace_management_panel.dart b/lib/features/workspace_management/workspace_management_panel.dart new file mode 100644 index 00000000..5033da84 --- /dev/null +++ b/lib/features/workspace_management/workspace_management_panel.dart @@ -0,0 +1,244 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../app/app_controller.dart'; +import '../../i18n/app_language.dart'; +import '../../runtime/runtime_models.dart'; +import '../../widgets/surface_card.dart'; +import 'workspace_management_form.dart'; +import 'workspace_management_i18n.dart'; +import 'workspace_management_result.dart'; +import 'workspace_management_steps.dart'; +import 'workspace_provision_controller.dart'; + +class WorkspaceManagementPanel extends StatefulWidget { + const WorkspaceManagementPanel({ + super.key, + required this.appController, + WorkspaceProvisionController? provisionController, + }) : _provisionController = provisionController; + + final AppController appController; + final WorkspaceProvisionController? _provisionController; + + static Future show(BuildContext context, AppController controller) { + return showDialog( + context: context, + builder: (_) => WorkspaceManagementPanel(appController: controller), + ); + } + + @override + State createState() => + _WorkspaceManagementPanelState(); +} + +class _WorkspaceManagementPanelState extends State { + late final WorkspaceProvisionController _controller; + late final bool _ownsController; + + @override + void initState() { + super.initState(); + _ownsController = widget._provisionController == null; + _controller = + widget._provisionController ?? + WorkspaceProvisionController( + initialWorkspaceDomain: _initialWorkspaceDomain(), + ); + } + + @override + void dispose() { + if (_ownsController) { + _controller.dispose(); + } + super.dispose(); + } + + String _initialWorkspaceDomain() { + final connection = widget.appController.connection; + if (connection.status == RuntimeConnectionStatus.connected) { + final remote = connection.remoteAddress?.trim() ?? ''; + final parsed = Uri.tryParse(remote.contains('://') ? remote : 'https://$remote'); + if (parsed != null && parsed.host.trim().isNotEmpty) { + return parsed.host.trim(); + } + } + return widget.appController.settings.primaryGatewayProfile.host.trim(); + } + + Future _confirmCreate() async { + if (!_controller.canSubmit) { + await _controller.createWorkspace(); + return; + } + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(appText('确认创建工作空间', 'Confirm workspace creation')), + content: Text( + appText( + '即将在 ${_controller.serverAddress} 上创建 AI 工作空间。\n\n' + '域名: ${_controller.workspaceDomain}\n' + 'SSH 用户: ${_controller.sshUsername}\n\n' + '该操作会安装系统依赖、配置服务和启动 systemd 服务,请确认这是你自己的服务器。', + 'XWorkmate will create an AI Workspace on ${_controller.serverAddress}.\n\n' + 'Domain: ${_controller.workspaceDomain}\n' + 'SSH user: ${_controller.sshUsername}\n\n' + 'This installs system dependencies, configures services, and starts systemd services. Confirm this is your own server.', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(appText('确认创建', 'Create')), + ), + ], + ), + ); + if (confirmed == true) { + unawaited(_controller.createWorkspace(installMissingPrerequisites: true)); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Dialog( + insetPadding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 980, maxHeight: 820), + child: SurfaceCard( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 14, 8), + child: Row( + children: [ + Icon( + Icons.dns_outlined, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + WorkspaceManagementText.title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + onPressed: _controller.isBusy + ? null + : () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: appText('关闭', 'Close'), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + WorkspaceManagementForm( + controller: _controller, + onDetect: () => unawaited(_controller.detectServer()), + onCreate: () => unawaited(_confirmCreate()), + ), + const SizedBox(height: 20), + WorkspaceManagementSteps(steps: _controller.steps), + const SizedBox(height: 12), + _LogPanel(controller: _controller), + const SizedBox(height: 12), + WorkspaceManagementResult(controller: _controller), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _LogPanel extends StatelessWidget { + const _LogPanel({required this.controller}); + + final WorkspaceProvisionController controller; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + TextButton.icon( + key: const Key('workspace-management-log-toggle'), + onPressed: () => controller.updateForm( + logsExpanded: !controller.logsExpanded, + ), + icon: Icon( + controller.logsExpanded ? Icons.expand_less : Icons.expand_more, + ), + label: Text(WorkspaceManagementText.logs), + ), + const Spacer(), + if (controller.logsExpanded) + IconButton( + onPressed: () => Clipboard.setData( + ClipboardData(text: controller.logBuffer.text), + ), + icon: const Icon(Icons.copy_outlined), + tooltip: WorkspaceManagementText.copyLogs, + ), + ], + ), + if (controller.logsExpanded) + Container( + key: const Key('workspace-management-log-content'), + constraints: const BoxConstraints(maxHeight: 220), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.45, + ), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SingleChildScrollView( + child: SelectableText( + controller.logBuffer.text.isEmpty + ? appText('暂无日志', 'No logs yet') + : controller.logBuffer.text, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/workspace_management/workspace_management_result.dart b/lib/features/workspace_management/workspace_management_result.dart new file mode 100644 index 00000000..4e5b9a7a --- /dev/null +++ b/lib/features/workspace_management/workspace_management_result.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../i18n/app_language.dart'; +import 'workspace_management_i18n.dart'; +import 'workspace_provision_controller.dart'; +import 'workspace_provision_models.dart'; + +class WorkspaceManagementResult extends StatelessWidget { + const WorkspaceManagementResult({super.key, required this.controller}); + + final WorkspaceProvisionController controller; + + @override + Widget build(BuildContext context) { + if (controller.phase == ProvisionPhase.success) { + return _success(context); + } + if (controller.phase == ProvisionPhase.failed) { + return _failure(context); + } + return const SizedBox.shrink(); + } + + Widget _success(BuildContext context) { + final theme = Theme.of(context); + final result = controller.deploymentResult; + final url = result?.url ?? ''; + final token = result?.bridgeToken ?? ''; + return Container( + key: const Key('workspace-management-result-success'), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.withValues(alpha: 0.35)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.check_circle, color: Colors.green), + const SizedBox(width: 8), + Text( + WorkspaceManagementText.ready, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + if (url.isNotEmpty) ...[ + const SizedBox(height: 8), + SelectableText(url), + const SizedBox(height: 8), + Text( + appText('预生成 Bridge Token', 'Pre-generated bridge token'), + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + SelectableText(token), + const SizedBox(height: 10), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon( + onPressed: () => Clipboard.setData(ClipboardData(text: url)), + icon: const Icon(Icons.copy_outlined), + label: Text(WorkspaceManagementText.copyAddress), + ), + OutlinedButton.icon( + onPressed: () => Clipboard.setData(ClipboardData(text: token)), + icon: const Icon(Icons.key_outlined), + label: Text(appText('复制 Token', 'Copy token')), + ), + OutlinedButton.icon( + onPressed: result == null ? null : () => _downloadResult(result), + icon: const Icon(Icons.download_outlined), + label: Text(appText('下载凭据', 'Download credentials')), + ), + FilledButton.tonalIcon( + onPressed: null, + icon: const Icon(Icons.settings_remote_outlined), + label: Text(WorkspaceManagementText.connectToWorkspace), + ), + ], + ), + ], + ], + ), + ); + } + + Future _downloadResult(WorkspaceDeploymentResult result) async { + final location = await getSaveLocation( + suggestedName: 'xworkmate-bridge-credentials.txt', + ); + if (location == null) { + return; + } + await File(location.path).writeAsString(result.downloadText); + } + + Widget _failure(BuildContext context) { + final theme = Theme.of(context); + return Container( + key: const Key('workspace-management-result-failed'), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.error.withValues(alpha: 0.35)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, color: theme.colorScheme.error), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + WorkspaceManagementText.failed, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + controller.errorMessage ?? + appText('请查看日志。', 'Check logs.'), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/workspace_management/workspace_management_steps.dart b/lib/features/workspace_management/workspace_management_steps.dart new file mode 100644 index 00000000..efa4a3cf --- /dev/null +++ b/lib/features/workspace_management/workspace_management_steps.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +import '../../i18n/app_language.dart'; +import 'workspace_provision_models.dart'; + +class WorkspaceManagementSteps extends StatelessWidget { + const WorkspaceManagementSteps({super.key, required this.steps}); + + final List steps; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + key: const Key('workspace-management-steps'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + appText('执行进度', 'Progress'), + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + for (final step in steps) + _StepRow(step: step, isLast: step == steps.last), + ], + ), + ), + ], + ); + } +} + +class _StepRow extends StatelessWidget { + const _StepRow({required this.step, required this.isLast}); + + final ProvisionStep step; + final bool isLast; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = _color(theme); + return Container( + key: Key('workspace-management-step-${step.id}'), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: isLast + ? null + : Border( + bottom: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 24, height: 24, child: _icon(color)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if ((step.message ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + step.message!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Color _color(ThemeData theme) { + return switch (step.status) { + StepStatus.success => Colors.green, + StepStatus.failed => theme.colorScheme.error, + StepStatus.running => theme.colorScheme.primary, + StepStatus.skipped => theme.colorScheme.tertiary, + StepStatus.pending => theme.colorScheme.outline, + }; + } + + Widget _icon(Color color) { + return switch (step.status) { + StepStatus.running => CircularProgressIndicator( + strokeWidth: 2, + color: color, + ), + StepStatus.success => Icon(Icons.check_circle, color: color, size: 20), + StepStatus.failed => Icon(Icons.cancel, color: color, size: 20), + StepStatus.skipped => Icon(Icons.remove_circle, color: color, size: 20), + StepStatus.pending => Icon(Icons.radio_button_unchecked, color: color, size: 20), + }; + } +} diff --git a/lib/features/workspace_management/workspace_provision_controller.dart b/lib/features/workspace_management/workspace_provision_controller.dart new file mode 100644 index 00000000..4daced08 --- /dev/null +++ b/lib/features/workspace_management/workspace_provision_controller.dart @@ -0,0 +1,313 @@ +import 'package:flutter/foundation.dart'; + +import '../../i18n/app_language.dart'; +import 'playbook_runner.dart'; +import 'server_detector.dart'; +import 'ssh_executor.dart'; +import 'workspace_provision_models.dart'; + +class WorkspaceProvisionController extends ChangeNotifier { + WorkspaceProvisionController({ + WorkspaceSshExecutor? executor, + String initialWorkspaceDomain = '', + }) : executor = executor ?? const DartSshExecutor(), + workspaceDomain = initialWorkspaceDomain { + steps = defaultProvisionSteps(); + } + + final WorkspaceSshExecutor executor; + + String serverAddress = ''; + String workspaceDomain = ''; + String sshUsername = 'root'; + AuthMethod authMethod = AuthMethod.sshKey; + String? sshPassword; + String? sshKeyContent; + String? sshKeyPath; + int sshPort = 22; + String? sudoPassword; + String installPath = '/opt/xworkspace/playbooks'; + bool showAdvanced = false; + bool logsExpanded = false; + + ProvisionPhase phase = ProvisionPhase.idle; + late List steps; + final ProvisionLogBuffer logBuffer = ProvisionLogBuffer(); + ServerInfo? serverInfo; + WorkspaceDeploymentResult? deploymentResult; + String? errorMessage; + + bool get isBusy => + phase == ProvisionPhase.checking || phase == ProvisionPhase.running; + + bool get canSubmit { + final hasAuth = switch (authMethod) { + AuthMethod.password => (sshPassword ?? '').trim().isNotEmpty, + AuthMethod.sshKey => + (sshKeyContent ?? '').trim().isNotEmpty || + (sshKeyPath ?? '').trim().isNotEmpty, + }; + return serverAddress.trim().isNotEmpty && + workspaceDomain.trim().isNotEmpty && + sshUsername.trim().isNotEmpty && + sshPort > 0 && + hasAuth; + } + + SshConfig sshConfig() { + return SshConfig( + host: serverAddress.trim(), + port: sshPort, + username: sshUsername.trim(), + authMethod: authMethod, + password: sshPassword, + privateKey: sshKeyContent, + privateKeyPath: sshKeyPath, + sudoPassword: sudoPassword, + ); + } + + Future detectServer() async { + if (!canSubmit) { + _fail(WorkspaceProvisionValidationException()); + return; + } + _prepareRun(ProvisionPhase.checking); + _setStep('ssh_connect', StepStatus.running, null); + try { + final detected = await ServerDetector(executor).detect( + sshConfig(), + workspaceDomain.trim(), + ); + serverInfo = detected; + _setStep('ssh_connect', StepStatus.success, null); + final blockingIssue = validatePrecheckBlockingIssueFor(detected); + _setStep( + 'detect_env', + blockingIssue == null ? StepStatus.success : StepStatus.failed, + blockingIssue ?? detected.displaySummary, + ); + if (blockingIssue != null) { + _fail(WorkspaceProvisionPrecheckException(blockingIssue)); + return; + } + phase = ProvisionPhase.ready; + _appendLog(appText('服务器检测完成。', 'Server detection completed.')); + notifyListeners(); + } catch (error) { + _setStep('ssh_connect', StepStatus.failed, error.toString()); + _fail(error); + } + } + + Future createWorkspace({bool installMissingPrerequisites = false}) async { + if (!canSubmit) { + _fail(WorkspaceProvisionValidationException()); + return; + } + _prepareRun(ProvisionPhase.running, keepDetection: true); + try { + if (serverInfo == null) { + final detected = await ServerDetector(executor).detect( + sshConfig(), + workspaceDomain.trim(), + ); + serverInfo = detected; + } + final blockingIssue = validatePrecheckBlockingIssue(); + if (blockingIssue != null) { + _setStep('detect_env', StepStatus.failed, blockingIssue); + throw WorkspaceProvisionPrecheckException(blockingIssue); + } + if (serverInfo != null) { + _setStep('ssh_connect', StepStatus.success, null); + _setStep('detect_env', StepStatus.success, serverInfo!.displaySummary); + } + final bridgeToken = ensureBridgeToken(); + await PlaybookRunner(executor).run( + ssh: sshConfig(), + action: 'create', + workspaceDomain: workspaceDomain.trim(), + bridgeToken: bridgeToken, + installPath: installPath.trim(), + installMissingPrerequisites: installMissingPrerequisites, + serverInfo: serverInfo, + onStepUpdate: _setStep, + onLog: _appendLog, + ); + for (final step in steps) { + if (step.status == StepStatus.pending || step.status == StepStatus.running) { + _setStep(step.id, StepStatus.success, null); + } + } + phase = ProvisionPhase.success; + deploymentResult = WorkspaceDeploymentResult( + url: 'https://${workspaceDomain.trim()}', + bridgeToken: bridgeToken, + ); + errorMessage = null; + _appendLog(appText('工作空间创建完成。', 'Workspace creation completed.')); + notifyListeners(); + } catch (error) { + _fail(error); + } + } + + Future upgradeWorkspace() async { + _fail( + PlaybookRunException( + appText( + '升级功能等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用。', + 'Upgrade waits for upgrade-ai-workspace.yml in the playbooks repository.', + ), + ), + ); + } + + void reset() { + phase = ProvisionPhase.idle; + steps = defaultProvisionSteps(); + logBuffer.clear(); + serverInfo = null; + deploymentResult = null; + errorMessage = null; + notifyListeners(); + } + + void updateForm({ + String? serverAddress, + String? workspaceDomain, + String? sshUsername, + AuthMethod? authMethod, + String? sshPassword, + String? sshKeyContent, + String? sshKeyPath, + int? sshPort, + String? sudoPassword, + String? installPath, + bool? showAdvanced, + bool? logsExpanded, + }) { + this.serverAddress = serverAddress ?? this.serverAddress; + this.workspaceDomain = workspaceDomain ?? this.workspaceDomain; + this.sshUsername = sshUsername ?? this.sshUsername; + this.authMethod = authMethod ?? this.authMethod; + this.sshPassword = sshPassword ?? this.sshPassword; + this.sshKeyContent = sshKeyContent ?? this.sshKeyContent; + this.sshKeyPath = sshKeyPath ?? this.sshKeyPath; + this.sshPort = sshPort ?? this.sshPort; + this.sudoPassword = sudoPassword ?? this.sudoPassword; + this.installPath = installPath ?? this.installPath; + this.showAdvanced = showAdvanced ?? this.showAdvanced; + this.logsExpanded = logsExpanded ?? this.logsExpanded; + notifyListeners(); + } + + void _prepareRun(ProvisionPhase nextPhase, {bool keepDetection = false}) { + phase = nextPhase; + errorMessage = null; + deploymentResult = null; + logBuffer.clear(); + final existingInfo = keepDetection ? serverInfo : null; + steps = defaultProvisionSteps(); + serverInfo = existingInfo; + notifyListeners(); + } + + void _setStep(String stepId, StepStatus status, String? message) { + final index = steps.indexWhere((step) => step.id == stepId); + if (index < 0) { + return; + } + final step = steps[index]; + step.status = status; + step.message = message ?? step.message; + if (status == StepStatus.running) { + step.startedAt ??= DateTime.now(); + step.finishedAt = null; + } + if (status == StepStatus.success || + status == StepStatus.failed || + status == StepStatus.skipped) { + step.finishedAt = DateTime.now(); + } + if (status == StepStatus.failed) { + step.errorDetail = message; + } + notifyListeners(); + } + + void _appendLog(String line) { + logBuffer.add(line); + notifyListeners(); + } + + void _fail(Object error) { + phase = ProvisionPhase.failed; + errorMessage = error.toString(); + _appendLog(errorMessage ?? ''); + notifyListeners(); + } + + String ensureBridgeToken() { + deploymentResult ??= WorkspaceDeploymentResult( + url: 'https://${workspaceDomain.trim()}', + bridgeToken: generateBridgeToken(), + ); + return deploymentResult!.bridgeToken; + } + + String? validatePrecheckBlockingIssue() { + return validatePrecheckBlockingIssueFor(serverInfo); + } + + String? validatePrecheckBlockingIssueFor(ServerInfo? info) { + if (info == null) { + return null; + } + if (!info.dnsResolved) { + return appText( + '部署前需要先把 ${workspaceDomain.trim()} 做好 DNS 解析。', + 'Configure DNS for ${workspaceDomain.trim()} before deploying.', + ); + } + if (!info.port443Open) { + return appText( + '目标服务器的 443 端口未开放,请先放通 HTTPS 访问。', + 'Port 443 is not open on the target server. Allow HTTPS traffic first.', + ); + } + if (!info.isPort443Available) { + return appText( + '目标服务器的 443 端口已被占用,请先释放。', + 'Port 443 is already in use on the target server.', + ); + } + return null; + } + + @override + void dispose() { + sshPassword = null; + sshKeyContent = null; + sudoPassword = null; + super.dispose(); + } +} + +class WorkspaceProvisionPrecheckException implements Exception { + const WorkspaceProvisionPrecheckException(this.message); + + final String message; + + @override + String toString() => message; +} + +class WorkspaceProvisionValidationException implements Exception { + @override + String toString() => appText( + '请填写服务器地址、Workspace 域名和认证信息。', + 'Enter server address, workspace domain, and authentication.', + ); +} diff --git a/lib/features/workspace_management/workspace_provision_models.dart b/lib/features/workspace_management/workspace_provision_models.dart new file mode 100644 index 00000000..f0af4d4c --- /dev/null +++ b/lib/features/workspace_management/workspace_provision_models.dart @@ -0,0 +1,231 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:math'; + +import '../../i18n/app_language.dart'; + +enum AuthMethod { password, sshKey } + +enum ProvisionPhase { idle, checking, ready, running, success, failed } + +enum StepStatus { pending, running, success, failed, skipped } + +class ProvisionStep { + ProvisionStep({ + required this.id, + required this.title, + required this.phaseGroup, + this.status = StepStatus.pending, + this.startedAt, + this.finishedAt, + this.message, + this.errorDetail, + }); + + final String id; + final String title; + final String phaseGroup; + StepStatus status; + DateTime? startedAt; + DateTime? finishedAt; + String? message; + String? errorDetail; + + ProvisionStep copy() { + return ProvisionStep( + id: id, + title: title, + phaseGroup: phaseGroup, + status: status, + startedAt: startedAt, + finishedAt: finishedAt, + message: message, + errorDetail: errorDetail, + ); + } +} + +class ServerInfo { + const ServerInfo({ + required this.os, + required this.arch, + required this.sudoAvailable, + required this.dockerVersion, + required this.systemdVersion, + required this.caddyVersion, + required this.ansibleVersion, + required this.gitVersion, + required this.dnsAddressCount, + required this.port443ListenerCount, + required this.port443Open, + }); + + final String os; + final String arch; + final bool sudoAvailable; + final String dockerVersion; + final String systemdVersion; + final String caddyVersion; + final String ansibleVersion; + final String gitVersion; + final int dnsAddressCount; + final int port443ListenerCount; + final bool port443Open; + + bool get gitMissing => _isMissing(gitVersion); + bool get ansibleMissing => _isMissing(ansibleVersion); + bool get hasMissingPrerequisites => gitMissing || ansibleMissing; + bool get dnsResolved => dnsAddressCount > 0; + bool get isPort443Available => port443ListenerCount == 0; + + String get displaySummary { + final sudo = sudoAvailable ? 'sudo=yes' : 'sudo=no'; + return [ + if (os.trim().isNotEmpty) os.trim(), + if (arch.trim().isNotEmpty) arch.trim(), + sudo, + dnsResolved ? 'dns=ok' : 'dns=missing', + port443Open ? '443=open' : '443=blocked', + isPort443Available ? '443=free' : '443=busy', + ].join(', '); + } + + static bool _isMissing(String value) => + value.trim().isEmpty || value.trim().toLowerCase() == 'missing'; +} + +class SshConfig { + const SshConfig({ + required this.host, + required this.port, + required this.username, + required this.authMethod, + this.password, + this.privateKey, + this.privateKeyPath, + this.sudoPassword, + this.connectTimeout = const Duration(seconds: 10), + }); + + final String host; + final int port; + final String username; + final AuthMethod authMethod; + final String? password; + final String? privateKey; + final String? privateKeyPath; + final String? sudoPassword; + final Duration connectTimeout; + + String get targetLabel => '$username@$host:$port'; +} + +class SshResult { + const SshResult({ + required this.exitCode, + required this.stdout, + required this.stderr, + }); + + final int exitCode; + final String stdout; + final String stderr; + + bool get success => exitCode == 0; + String get combinedOutput { + if (stderr.trim().isEmpty) { + return stdout; + } + if (stdout.trim().isEmpty) { + return stderr; + } + return '$stdout\n$stderr'; + } +} + +class ProvisionLogBuffer { + ProvisionLogBuffer({this.maxLines = 500}); + + final int maxLines; + final ListQueue _lines = ListQueue(); + + void add(String line, {DateTime? now}) { + final timestamp = (now ?? DateTime.now()).toIso8601String(); + _lines.add('[$timestamp] $line'); + while (_lines.length > maxLines) { + _lines.removeFirst(); + } + } + + void clear() => _lines.clear(); + + List get lines => List.unmodifiable(_lines); + + String get text => _lines.join('\n'); +} + +class WorkspaceDeploymentResult { + const WorkspaceDeploymentResult({ + required this.url, + required this.bridgeToken, + }); + + final String url; + final String bridgeToken; + + String get downloadText { + return 'XWorkmate Bridge URL: $url\n' + 'Bridge Auth Token: $bridgeToken\n'; + } +} + +String generateBridgeToken({int length = 32}) { + final random = Random.secure(); + final bytes = List.generate(length, (_) => random.nextInt(256)); + return base64UrlEncode(bytes).replaceAll('=', ''); +} + +List defaultProvisionSteps() { + return [ + ProvisionStep( + id: 'ssh_connect', + title: appText('SSH 连接成功', 'SSH connected'), + phaseGroup: 'detect', + ), + ProvisionStep( + id: 'detect_env', + title: appText('检测系统环境', 'Detect system environment'), + phaseGroup: 'detect', + ), + ProvisionStep( + id: 'install_deps', + title: appText('安装基础依赖', 'Install base dependencies'), + phaseGroup: 'system', + ), + ProvisionStep( + id: 'deploy_webrtc', + title: appText('部署 WebRTC 远端桌面', 'Deploy WebRTC remote desktop'), + phaseGroup: 'console', + ), + ProvisionStep( + id: 'deploy_bridge', + title: appText('部署 XWorkmate Bridge', 'Deploy XWorkmate Bridge'), + phaseGroup: 'bridge', + ), + ProvisionStep( + id: 'config_caddy', + title: appText('配置 Caddy / TLS', 'Configure Caddy / TLS'), + phaseGroup: 'bridge', + ), + ProvisionStep( + id: 'config_gateway', + title: appText('配置 OpenClaw Gateway', 'Configure OpenClaw Gateway'), + phaseGroup: 'bridge', + ), + ProvisionStep( + id: 'start_services', + title: appText('启动系统服务', 'Start system services'), + phaseGroup: 'bridge', + ), + ]; +} diff --git a/pubspec.lock b/pubspec.lock index 07740240..ce235149 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: @@ -57,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cross_file: dependency: transitive description: @@ -105,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.1" + dartssh2: + dependency: "direct main" + description: + name: dartssh2 + sha256: c139babed0d6851449100010639115e1ed88decf0db7eb714ce13935c7eb590c + url: "https://pub.dev" + source: hosted + version: "2.17.1" device_info_plus: dependency: "direct main" description: @@ -523,6 +547,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pinenacl: + dependency: transitive + description: + name: pinenacl + sha256: "57e907beaacbc3c024a098910b6240758e899674de07d6949a67b52fd984cbdf" + url: "https://pub.dev" + source: hosted + version: "0.6.0" pixel_snap: dependency: transitive description: @@ -547,6 +579,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" process: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4db29340..acb2f7c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: cupertino_icons: ^1.0.8 cryptography: ^2.6.1 crypto: ^3.0.6 + dartssh2: ^2.17.1 device_info_plus: ^11.5.0 file_selector: ^1.0.3 flutter_html: ^3.0.0 diff --git a/test/features/settings/settings_remote_desktop_panel_test.dart b/test/features/settings/settings_remote_desktop_panel_test.dart index 96c8054e..544ea1f3 100644 --- a/test/features/settings/settings_remote_desktop_panel_test.dart +++ b/test/features/settings/settings_remote_desktop_panel_test.dart @@ -31,6 +31,7 @@ void main() { // Verify the panel headers and titles expect(find.text('AI工作空间'), findsOneWidget); expect(find.text('连接AI工作空间'), findsOneWidget); + expect(find.text('工作空间管理'), findsOneWidget); // Verify advanced options are hidden initially expect(find.text('GPU 加速'), findsNothing); @@ -43,6 +44,7 @@ void main() { expect(find.text('GPU 加速'), findsOneWidget); expect(find.widgetWithText(TextField, 'Display'), findsOneWidget); expect(find.text('Display'), findsOneWidget); + expect(find.text('工作空间管理'), findsOneWidget); }); }); } diff --git a/test/features/workspace_management/workspace_management_unit_test.dart b/test/features/workspace_management/workspace_management_unit_test.dart new file mode 100644 index 00000000..c04054e9 --- /dev/null +++ b/test/features/workspace_management/workspace_management_unit_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/workspace_management/playbook_runner.dart'; +import 'package:xworkmate/features/workspace_management/server_detector.dart'; +import 'package:xworkmate/features/workspace_management/ssh_executor.dart'; +import 'package:xworkmate/features/workspace_management/workspace_provision_controller.dart'; +import 'package:xworkmate/features/workspace_management/workspace_provision_models.dart'; + +void main() { + group('workspace management models and parsers', () { + test('default steps include the v1 provisioning flow', () { + final steps = defaultProvisionSteps(); + + expect(steps.map((step) => step.id), [ + 'ssh_connect', + 'detect_env', + 'install_deps', + 'deploy_webrtc', + 'deploy_bridge', + 'config_caddy', + 'config_gateway', + 'start_services', + ]); + expect(steps.first.status, StepStatus.pending); + }); + + test('log buffer keeps only the newest lines', () { + final buffer = ProvisionLogBuffer(maxLines: 2); + + buffer.add('one'); + buffer.add('two'); + buffer.add('three'); + + expect(buffer.lines.length, 2); + expect(buffer.text, contains('two')); + expect(buffer.text, contains('three')); + expect(buffer.text, isNot(contains('one'))); + }); + + test('server detector parses command output', () { + final info = ServerDetector.parseServerInfo(''' +OS=Ubuntu 22.04.4 LTS +ARCH=x86_64 +SUDO=yes +DOCKER=missing +SYSTEMD=systemd 249 +CADDY=missing +ANSIBLE=missing +GIT=git version 2.34.1 +DNS_OK=1 +PORT_443_LISTENERS=0 +PORT_443_OPEN=yes +'''); + + expect(info.os, 'Ubuntu 22.04.4 LTS'); + expect(info.arch, 'x86_64'); + expect(info.sudoAvailable, isTrue); + expect(info.ansibleMissing, isTrue); + expect(info.gitMissing, isFalse); + expect(info.dnsResolved, isTrue); + expect(info.port443Open, isTrue); + expect(info.isPort443Available, isTrue); + }); + + test('ansible parser maps human readable output to step events', () { + final parser = AnsibleOutputParser(); + + final start = parser.parseLine('TASK [Configure caddy TLS]'); + final ok = parser.parseLine('changed: [localhost]'); + + expect(start?.stepId, 'config_caddy'); + expect(start?.status, StepStatus.running); + expect(ok?.stepId, 'config_caddy'); + expect(ok?.status, StepStatus.success); + }); + + test('detection command quotes workspace domain', () { + final command = ServerDetector.detectionCommand("a'b.example.com"); + + expect(command, contains("'a'\"'\"'b.example.com'")); + expect(command, contains('getent hosts')); + }); + }); + + group('WorkspaceProvisionController', () { + test('detectServer moves to ready with parsed server info', () async { + final controller = WorkspaceProvisionController( + executor: _FakeSshExecutor( + commandResults: [ + const SshResult( + exitCode: 0, + stdout: ''' +OS=Ubuntu 24.04 LTS +ARCH=x86_64 +SUDO=yes +DOCKER=missing +SYSTEMD=systemd 255 +CADDY=missing +ANSIBLE=ansible [core 2.16] +GIT=git version 2.43.0 +DNS_OK=1 +PORT_443_LISTENERS=0 +PORT_443_OPEN=yes +''', + stderr: '', + ), + ], + ), + ); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'workspace.example.com', + sshKeyContent: 'key', + ); + + await controller.detectServer(); + + expect(controller.phase, ProvisionPhase.ready); + expect(controller.serverInfo?.os, 'Ubuntu 24.04 LTS'); + expect( + controller.steps.firstWhere((step) => step.id == 'detect_env').status, + StepStatus.success, + ); + }); + + test('createWorkspace runs playbook flow with fake SSH', () async { + final executor = _FakeSshExecutor( + commandResults: [ + const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''), + const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''), + ], + streamingChunks: [ + 'TASK [Install desktop packages]\nok: [localhost]\n', + 'TASK [Configure caddy TLS]\nchanged: [localhost]\n', + ], + ); + final controller = WorkspaceProvisionController(executor: executor); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'workspace.example.com', + sshKeyContent: 'key', + ); + controller.serverInfo = const ServerInfo( + os: 'Ubuntu 22.04', + arch: 'x86_64', + sudoAvailable: true, + dockerVersion: 'missing', + systemdVersion: 'systemd 249', + caddyVersion: 'missing', + ansibleVersion: 'ansible [core 2.14]', + gitVersion: 'git version 2.34.1', + dnsAddressCount: 1, + port443ListenerCount: 0, + port443Open: true, + ); + + await controller.createWorkspace(); + + expect(controller.phase, ProvisionPhase.success); + expect(controller.deploymentResult?.url, 'https://workspace.example.com'); + expect(controller.deploymentResult?.bridgeToken, isNotEmpty); + expect(executor.commands.join('\n'), contains('ansible-playbook')); + }); + + test('precheck blocks when 443 is not open', () async { + final controller = WorkspaceProvisionController(executor: _FakeSshExecutor()); + addTearDown(controller.dispose); + controller.updateForm( + serverAddress: '203.0.113.10', + workspaceDomain: 'xworkmate-bridge.example.com', + sshKeyContent: 'key', + ); + controller.serverInfo = const ServerInfo( + os: 'Ubuntu 22.04', + arch: 'x86_64', + sudoAvailable: true, + dockerVersion: 'missing', + systemdVersion: 'systemd 249', + caddyVersion: 'missing', + ansibleVersion: 'ansible [core 2.14]', + gitVersion: 'git version 2.34.1', + dnsAddressCount: 1, + port443ListenerCount: 0, + port443Open: false, + ); + + expect( + controller.validatePrecheckBlockingIssue(), + contains('443'), + ); + }); + }); +} + +class _FakeSshExecutor implements WorkspaceSshExecutor { + _FakeSshExecutor({ + this.commandResults = const [], + this.streamingChunks = const [], + }); + + final List commandResults; + final List streamingChunks; + final List commands = []; + int _commandIndex = 0; + + @override + Future execute(SshConfig config, String command) async { + commands.add(command); + return commandResults[_commandIndex++]; + } + + @override + Stream executeStreaming(SshConfig config, String command) async* { + commands.add(command); + for (final chunk in streamingChunks) { + yield chunk; + } + } +} diff --git a/test/features/workspace_management/workspace_management_widget_test.dart b/test/features/workspace_management/workspace_management_widget_test.dart new file mode 100644 index 00000000..225203d6 --- /dev/null +++ b/test/features/workspace_management/workspace_management_widget_test.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/features/workspace_management/ssh_executor.dart'; +import 'package:xworkmate/features/workspace_management/workspace_management_panel.dart'; +import 'package:xworkmate/features/workspace_management/workspace_provision_controller.dart'; +import 'package:xworkmate/features/workspace_management/workspace_provision_models.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +void main() { + testWidgets('panel renders form controls and keeps upgrade disabled', ( + tester, + ) async { + final appController = _NoopAppController(store: _MemorySecureConfigStore()); + final provisionController = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + initialWorkspaceDomain: 'workspace.example.com', + ); + addTearDown(() { + provisionController.dispose(); + appController.dispose(); + }); + + await tester.pumpWidget( + _buildApp( + WorkspaceManagementPanel( + appController: appController, + provisionController: provisionController, + ), + ), + ); + + expect(find.text('创建 / 升级 AI 工作空间'), findsOneWidget); + expect(find.text('workspace.example.com'), findsOneWidget); + expect(find.byKey(const Key('workspace-management-upgrade-button')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('workspace-management-upgrade-button')), + ) + .onPressed, + isNull, + ); + + await tester.tap(find.text('高级选项')); + await tester.pumpAndSettle(); + + expect(find.text('安装路径'), findsOneWidget); + }); + + testWidgets('panel switches auth method and expands logs', (tester) async { + final appController = _NoopAppController(store: _MemorySecureConfigStore()); + final provisionController = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + ); + addTearDown(() { + provisionController.dispose(); + appController.dispose(); + }); + + await tester.pumpWidget( + _buildApp( + WorkspaceManagementPanel( + appController: appController, + provisionController: provisionController, + ), + ), + ); + + await tester.tap(find.text('密码')); + await tester.pumpAndSettle(); + expect(find.text('SSH 密码 *'), findsOneWidget); + + provisionController.logBuffer.add('hello log'); + provisionController.updateForm(logsExpanded: true); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('workspace-management-log-content')), findsOneWidget); + expect(find.textContaining('hello log'), findsOneWidget); + }); + + testWidgets('success result shows url and bridge token', (tester) async { + final appController = _NoopAppController(store: _MemorySecureConfigStore()); + final provisionController = WorkspaceProvisionController( + executor: _FakeSshExecutor(), + ); + addTearDown(() { + provisionController.dispose(); + appController.dispose(); + }); + provisionController.deploymentResult = const WorkspaceDeploymentResult( + url: 'https://xworkmate-bridge.example.com', + bridgeToken: 'bridge-token-123', + ); + provisionController.phase = ProvisionPhase.success; + + await tester.pumpWidget( + _buildApp( + WorkspaceManagementPanel( + appController: appController, + provisionController: provisionController, + ), + ), + ); + + expect(find.text('https://xworkmate-bridge.example.com'), findsOneWidget); + expect(find.text('bridge-token-123'), findsOneWidget); + expect(find.text('下载凭据'), findsOneWidget); + }); +} + +Widget _buildApp(Widget child) { + return MaterialApp( + theme: AppTheme.light(), + home: Material(child: child), + ); +} + +class _FakeSshExecutor implements WorkspaceSshExecutor { + @override + Future execute(SshConfig config, String command) async { + return const SshResult(exitCode: 0, stdout: '', stderr: ''); + } + + @override + Stream executeStreaming(SshConfig config, String command) async* {} +} + +class _NoopAppController extends AppController { + _NoopAppController({required SecureConfigStore store}) + : super(environmentOverride: const {}, store: store); +} + +class _MemorySecureConfigStore extends SecureConfigStore { + _MemorySecureConfigStore() : super(enableSecureStorage: false); + + SettingsSnapshot _settings = SettingsSnapshot.defaults(); + final Map _secrets = {}; + + @override + Future initialize() async {} + + @override + Future loadSettingsSnapshot() async => _settings; + + @override + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + _settings = snapshot; + } + + @override + Future> loadSecureRefs() async => _secrets; + + @override + Future> loadAuditTrail() async => + const []; + + @override + Future appendAudit(SecretAuditEntry entry) async {} + + @override + Future loadSecretValueByRef(String refName) async => + _secrets[refName]; + + @override + Future saveSecretValueByRef(String refName, String value) async { + _secrets[refName] = value; + } + + @override + Future loadAccountSessionToken() async => null; + + @override + Future loadAccountSessionSummary() async => null; + + @override + Future loadAccountSyncState() async => null; +}