Add AI workspace management provisioning flow
This commit is contained in:
parent
f034e6f28e
commit
bc459d10c9
@ -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(
|
||||
|
||||
274
lib/features/workspace_management/playbook_runner.dart
Normal file
274
lib/features/workspace_management/playbook_runner.dart
Normal 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;
|
||||
}
|
||||
100
lib/features/workspace_management/server_detector.dart
Normal file
100
lib/features/workspace_management/server_detector.dart
Normal 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;
|
||||
}
|
||||
108
lib/features/workspace_management/ssh_executor.dart
Normal file
108
lib/features/workspace_management/ssh_executor.dart
Normal 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("'", "'\"'\"'")}'";
|
||||
}
|
||||
299
lib/features/workspace_management/workspace_management_form.dart
Normal file
299
lib/features/workspace_management/workspace_management_form.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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.',
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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.'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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.',
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
),
|
||||
];
|
||||
}
|
||||
40
pubspec.lock
40
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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user