Add AI workspace management provisioning flow

This commit is contained in:
Haitao Pan 2026-06-08 13:20:38 +08:00
parent f034e6f28e
commit bc459d10c9
16 changed files with 2315 additions and 0 deletions

View File

@ -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<DesktopView> {
),
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(

View File

@ -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<void> 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<void> _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<void> _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 <String>[
'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;
}

View File

@ -0,0 +1,100 @@
import 'workspace_provision_models.dart';
import 'ssh_executor.dart';
class ServerDetector {
const ServerDetector(this.executor);
final WorkspaceSshExecutor executor;
Future<ServerInfo> 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 = <String, String>{};
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;
}

View File

@ -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<SshResult> execute(SshConfig config, String command);
Stream<String> executeStreaming(SshConfig config, String command);
}
class DartSshExecutor implements WorkspaceSshExecutor {
const DartSshExecutor();
@override
Future<SshResult> 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<String> executeStreaming(SshConfig config, String command) async* {
final client = await _connect(config);
SSHSession? session;
try {
session = await client.execute(command);
final controller = StreamController<String>();
final subscriptions = <StreamSubscription<List<int>>>[
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<SSHClient> _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<List<SSHKeyPair>> _identities(SshConfig config) async {
if (config.authMethod != AuthMethod.sshKey) {
return const <SSHKeyPair>[];
}
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 <SSHKeyPair>[];
}
}
String shellQuote(String value) {
return "'${value.replaceAll("'", "'\"'\"'")}'";
}

View File

@ -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<WorkspaceManagementForm> createState() =>
_WorkspaceManagementFormState();
}
class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
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<AuthMethod>(
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),
),
),
);
}
}

View File

@ -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.',
);
}

View File

@ -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<void> show(BuildContext context, AppController controller) {
return showDialog<void>(
context: context,
builder: (_) => WorkspaceManagementPanel(appController: controller),
);
}
@override
State<WorkspaceManagementPanel> createState() =>
_WorkspaceManagementPanelState();
}
class _WorkspaceManagementPanelState extends State<WorkspaceManagementPanel> {
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<void> _confirmCreate() async {
if (!_controller.canSubmit) {
await _controller.createWorkspace();
return;
}
final confirmed = await showDialog<bool>(
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',
),
),
),
),
],
);
}
}

View File

@ -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<void> _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.'),
),
],
),
),
],
),
);
}
}

View File

@ -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<ProvisionStep> 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),
};
}
}

View File

@ -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<ProvisionStep> 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<void> 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<void> 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<void> 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.',
);
}

View File

@ -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<String> _lines = ListQueue<String>();
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<String> get lines => List<String>.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<int>.generate(length, (_) => random.nextInt(256));
return base64UrlEncode(bytes).replaceAll('=', '');
}
List<ProvisionStep> defaultProvisionSteps() {
return <ProvisionStep>[
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',
),
];
}

View File

@ -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:

View File

@ -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

View File

@ -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);
});
});
}

View File

@ -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 <SshResult>[],
this.streamingChunks = const <String>[],
});
final List<SshResult> commandResults;
final List<String> streamingChunks;
final List<String> commands = <String>[];
int _commandIndex = 0;
@override
Future<SshResult> execute(SshConfig config, String command) async {
commands.add(command);
return commandResults[_commandIndex++];
}
@override
Stream<String> executeStreaming(SshConfig config, String command) async* {
commands.add(command);
for (final chunk in streamingChunks) {
yield chunk;
}
}
}

View File

@ -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<FilledButton>(
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<SshResult> execute(SshConfig config, String command) async {
return const SshResult(exitCode: 0, stdout: '', stderr: '');
}
@override
Stream<String> executeStreaming(SshConfig config, String command) async* {}
}
class _NoopAppController extends AppController {
_NoopAppController({required SecureConfigStore store})
: super(environmentOverride: const <String, String>{}, store: store);
}
class _MemorySecureConfigStore extends SecureConfigStore {
_MemorySecureConfigStore() : super(enableSecureStorage: false);
SettingsSnapshot _settings = SettingsSnapshot.defaults();
final Map<String, String> _secrets = <String, String>{};
@override
Future<void> initialize() async {}
@override
Future<SettingsSnapshot> loadSettingsSnapshot() async => _settings;
@override
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
_settings = snapshot;
}
@override
Future<Map<String, String>> loadSecureRefs() async => _secrets;
@override
Future<List<SecretAuditEntry>> loadAuditTrail() async =>
const <SecretAuditEntry>[];
@override
Future<void> appendAudit(SecretAuditEntry entry) async {}
@override
Future<String?> loadSecretValueByRef(String refName) async =>
_secrets[refName];
@override
Future<void> saveSecretValueByRef(String refName, String value) async {
_secrets[refName] = value;
}
@override
Future<String?> loadAccountSessionToken() async => null;
@override
Future<AccountSessionSummary?> loadAccountSessionSummary() async => null;
@override
Future<AccountSyncState?> loadAccountSyncState() async => null;
}