feat(workspace): run remote setup script
This commit is contained in:
parent
5932b70f70
commit
4b08f76232
@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../i18n/app_language.dart';
|
||||
import 'server_detector.dart';
|
||||
import 'ssh_executor.dart';
|
||||
@ -8,9 +6,8 @@ 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';
|
||||
static const String setupScriptUrl =
|
||||
'https://raw.githubusercontent.com/ai-workspace-lab/xworkspace-console/main/scripts/setup-ai-workspace-all-in-one.sh';
|
||||
|
||||
final WorkspaceSshExecutor executor;
|
||||
|
||||
@ -20,74 +17,44 @@ class PlaybookRunner {
|
||||
required String workspaceDomain,
|
||||
required String bridgeDomain,
|
||||
required String bridgeToken,
|
||||
required String installPath,
|
||||
required bool installMissingPrerequisites,
|
||||
required ServerInfo? serverInfo,
|
||||
List<WorkspaceExtraConfig>? extraConfigs,
|
||||
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,
|
||||
bridgeDomain,
|
||||
);
|
||||
info = await ServerDetector(
|
||||
executor,
|
||||
).detect(ssh, workspaceDomain, bridgeDomain);
|
||||
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('检查 curl', 'Checking curl'),
|
||||
);
|
||||
await _executeChecked(ssh, _preflightInstallCommand(ssh), onLog);
|
||||
onStepUpdate(
|
||||
'install_deps',
|
||||
StepStatus.success,
|
||||
appText('curl 已就绪', 'curl is ready'),
|
||||
);
|
||||
|
||||
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,
|
||||
await _runSetupScript(
|
||||
ssh: ssh,
|
||||
command: setupScriptCommand(
|
||||
ssh: ssh,
|
||||
action: action,
|
||||
workspaceDomain: workspaceDomain,
|
||||
bridgeDomain: bridgeDomain,
|
||||
bridgeToken: bridgeToken,
|
||||
extraConfigs: extraConfigs,
|
||||
),
|
||||
onLog,
|
||||
);
|
||||
|
||||
await _runAnsible(
|
||||
ssh: ssh,
|
||||
command: _ansibleCommand(
|
||||
installPath: installPath,
|
||||
inventoryPath: inventoryPath,
|
||||
varsPath: varsPath,
|
||||
),
|
||||
onStepUpdate: onStepUpdate,
|
||||
onLog: onLog,
|
||||
);
|
||||
@ -109,15 +76,20 @@ class PlaybookRunner {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runAnsible({
|
||||
Future<void> _runSetupScript({
|
||||
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();
|
||||
final parser = SetupScriptOutputParser();
|
||||
var failed = false;
|
||||
onStepUpdate(
|
||||
'deploy_webrtc',
|
||||
StepStatus.running,
|
||||
appText('执行远程安装脚本', 'Running remote setup script'),
|
||||
);
|
||||
await for (final chunk in executor.executeStreaming(ssh, command)) {
|
||||
for (final raw in chunk.split(RegExp(r'\r?\n'))) {
|
||||
final line = raw.trimRight();
|
||||
@ -136,7 +108,9 @@ class PlaybookRunner {
|
||||
}
|
||||
}
|
||||
if (failed) {
|
||||
throw PlaybookRunException(appText('Playbook 执行失败。', 'Playbook execution failed.'));
|
||||
throw PlaybookRunException(
|
||||
appText('远程安装脚本执行失败。', 'Remote setup script failed.'),
|
||||
);
|
||||
}
|
||||
for (final id in <String>[
|
||||
'install_deps',
|
||||
@ -151,29 +125,24 @@ class PlaybookRunner {
|
||||
}
|
||||
|
||||
static String _preflightInstallCommand(SshConfig ssh) {
|
||||
final apt = 'DEBIAN_FRONTEND=noninteractive apt-get update && '
|
||||
'DEBIAN_FRONTEND=noninteractive apt-get install -y git ansible';
|
||||
final apt =
|
||||
'DEBIAN_FRONTEND=noninteractive apt-get update && '
|
||||
'DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates';
|
||||
if (ssh.username == 'root') {
|
||||
return apt;
|
||||
return 'if command -v curl >/dev/null 2>&1; then echo curl ready; else $apt; fi';
|
||||
}
|
||||
final sudoPassword = ssh.sudoPassword?.trim();
|
||||
if (sudoPassword != null && sudoPassword.isNotEmpty) {
|
||||
return "printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S sh -lc ${shellQuote(apt)}";
|
||||
return 'if command -v curl >/dev/null 2>&1; then echo curl ready; '
|
||||
"else printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S sh -lc ${shellQuote(apt)}; fi";
|
||||
}
|
||||
return 'sudo -n sh -lc ${shellQuote(apt)}';
|
||||
return 'if command -v curl >/dev/null 2>&1; then echo curl ready; '
|
||||
'else sudo -n sh -lc ${shellQuote(apt)}; fi';
|
||||
}
|
||||
|
||||
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,
|
||||
static String setupScriptCommand({
|
||||
required SshConfig ssh,
|
||||
required String action,
|
||||
required String workspaceDomain,
|
||||
required String bridgeDomain,
|
||||
required String bridgeToken,
|
||||
@ -182,91 +151,96 @@ class PlaybookRunner {
|
||||
final domain = workspaceDomain.trim();
|
||||
final bridge = bridgeDomain.trim();
|
||||
final bridgeUrl = 'https://$bridge';
|
||||
final extraEnvVars = <String>[];
|
||||
final env = <String, String>{
|
||||
'XWORKSPACE_SETUP_ACTION': action.trim().isEmpty
|
||||
? 'create'
|
||||
: action.trim(),
|
||||
'WORKSPACE_DOMAIN': domain,
|
||||
'XWORKSPACE_CONSOLE_DOMAIN': domain,
|
||||
'XWORKMATE_BRIDGE_DOMAIN': bridge,
|
||||
'XWORKMATE_BRIDGE_PUBLIC_BASE_URL': bridgeUrl,
|
||||
'XWORKMATE_BRIDGE_SERVICE_DOMAIN': bridge,
|
||||
'XWORKMATE_BRIDGE_SERVICE_PUBLIC_BASE_URL': bridgeUrl,
|
||||
'XWORKMATE_BRIDGE_AUTH_TOKEN': bridgeToken.trim(),
|
||||
'TOKEN': bridgeToken.trim(),
|
||||
};
|
||||
for (final config in extraConfigs ?? const <WorkspaceExtraConfig>[]) {
|
||||
final key = config.key.trim();
|
||||
final value = config.value.trim();
|
||||
if (key.isEmpty || value.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
extraEnvVars.add('$key: ${shellQuote(value)}');
|
||||
final note = config.note.trim();
|
||||
if (note.isNotEmpty) {
|
||||
extraEnvVars.add('# ${note.length > 20 ? note.substring(0, 20) : note}');
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
final extraEnvBlock =
|
||||
extraEnvVars.isEmpty ? '' : '${extraEnvVars.join('\n')}\n';
|
||||
return '''
|
||||
cat > ${shellQuote(inventoryPath)} <<'EOF'
|
||||
[all]
|
||||
localhost ansible_connection=local
|
||||
EOF
|
||||
cat > ${shellQuote(varsPath)} <<'EOF'
|
||||
workspace_domain: $domain
|
||||
xworkmate_bridge_domain: $bridge
|
||||
xworkmate_bridge_public_base_url: $bridgeUrl
|
||||
xworkmate_bridge_service_domain: $bridge
|
||||
xworkmate_bridge_service_public_base_url: $bridgeUrl
|
||||
xworkmate_bridge_auth_token: ${bridgeToken.trim()}
|
||||
${extraEnvBlock}EOF
|
||||
''';
|
||||
final envArgs = env.entries
|
||||
.where((entry) => _isValidEnvKey(entry.key))
|
||||
.map((entry) => '${entry.key}=${shellQuote(entry.value)}')
|
||||
.join(' ');
|
||||
final script =
|
||||
'set -o pipefail; curl -sfL ${shellQuote(setupScriptUrl)} | bash -';
|
||||
final command = 'env $envArgs bash -lc ${shellQuote(script)} 2>&1';
|
||||
if (ssh.username == 'root') {
|
||||
return command;
|
||||
}
|
||||
final sudoPassword = ssh.sudoPassword?.trim();
|
||||
if (sudoPassword != null && sudoPassword.isNotEmpty) {
|
||||
return "printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S $command";
|
||||
}
|
||||
return 'sudo -n $command';
|
||||
}
|
||||
|
||||
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';
|
||||
static bool _isValidEnvKey(String key) {
|
||||
return RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$').hasMatch(key);
|
||||
}
|
||||
}
|
||||
|
||||
class AnsibleStepEvent {
|
||||
const AnsibleStepEvent(this.stepId, this.status, this.message);
|
||||
class SetupScriptStepEvent {
|
||||
const SetupScriptStepEvent(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;
|
||||
}
|
||||
class SetupScriptOutputParser {
|
||||
SetupScriptStepEvent? parseLine(String line) {
|
||||
final lower = line.toLowerCase();
|
||||
if (lower.startsWith('fatal:') || lower.contains(' failed=')) {
|
||||
return AnsibleStepEvent(_currentStepId!, StepStatus.failed, line);
|
||||
if (lower.contains('error') ||
|
||||
lower.contains('failed') ||
|
||||
lower.contains('fatal')) {
|
||||
return SetupScriptStepEvent(
|
||||
stepIdForText(lower),
|
||||
StepStatus.failed,
|
||||
line,
|
||||
);
|
||||
}
|
||||
if (lower.startsWith('ok:') || lower.startsWith('changed:')) {
|
||||
return AnsibleStepEvent(_currentStepId!, StepStatus.success, _currentTask);
|
||||
final stepId = stepIdForText(lower);
|
||||
if (lower.contains('install') ||
|
||||
lower.contains('deploy') ||
|
||||
lower.contains('config') ||
|
||||
lower.contains('start') ||
|
||||
lower.contains('enable') ||
|
||||
lower.contains('caddy') ||
|
||||
lower.contains('gateway') ||
|
||||
lower.contains('bridge') ||
|
||||
lower.contains('xworkspace')) {
|
||||
return SetupScriptStepEvent(stepId, StepStatus.running, line);
|
||||
}
|
||||
if (lower.startsWith('skipping:')) {
|
||||
return AnsibleStepEvent(_currentStepId!, StepStatus.skipped, _currentTask);
|
||||
if (lower.contains('done') ||
|
||||
lower.contains('success') ||
|
||||
lower.contains('complete')) {
|
||||
return SetupScriptStepEvent(stepId, StepStatus.success, line);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String stepIdForTask(String task) {
|
||||
final text = task.toLowerCase();
|
||||
static String stepIdForText(String text) {
|
||||
if (text.contains('bridge') || text.contains('acp_server')) {
|
||||
return 'deploy_bridge';
|
||||
}
|
||||
if (text.contains('caddy') || text.contains('tls') || text.contains('cert')) {
|
||||
if (text.contains('caddy') ||
|
||||
text.contains('tls') ||
|
||||
text.contains('cert')) {
|
||||
return 'config_caddy';
|
||||
}
|
||||
if (text.contains('gateway') || text.contains('openclaw')) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../i18n/app_language.dart';
|
||||
import 'playbook_runner.dart';
|
||||
import 'workspace_provision_controller.dart';
|
||||
import 'workspace_provision_models.dart';
|
||||
|
||||
@ -10,11 +11,13 @@ class WorkspaceManagementForm extends StatefulWidget {
|
||||
required this.controller,
|
||||
required this.onDetect,
|
||||
required this.onCreate,
|
||||
required this.onUpgrade,
|
||||
});
|
||||
|
||||
final WorkspaceProvisionController controller;
|
||||
final VoidCallback onDetect;
|
||||
final VoidCallback onCreate;
|
||||
final VoidCallback onUpgrade;
|
||||
|
||||
@override
|
||||
State<WorkspaceManagementForm> createState() =>
|
||||
@ -47,7 +50,9 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
|
||||
_keyPathController = TextEditingController(text: c.sshKeyPath ?? '');
|
||||
_portController = TextEditingController(text: c.sshPort.toString());
|
||||
_sudoController = TextEditingController(text: c.sudoPassword ?? '');
|
||||
_installPathController = TextEditingController(text: c.installPath);
|
||||
_installPathController = TextEditingController(
|
||||
text: PlaybookRunner.setupScriptUrl,
|
||||
);
|
||||
for (final row in c.extraConfigs) {
|
||||
_extraRows.add(
|
||||
_ExtraRowControllers(
|
||||
@ -73,7 +78,7 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
|
||||
_syncText(_keyPathController, widget.controller.sshKeyPath ?? '');
|
||||
_syncText(_portController, widget.controller.sshPort.toString());
|
||||
_syncText(_sudoController, widget.controller.sudoPassword ?? '');
|
||||
_syncText(_installPathController, widget.controller.installPath);
|
||||
_syncText(_installPathController, PlaybookRunner.setupScriptUrl);
|
||||
_syncExtraRows(widget.controller.extraConfigs);
|
||||
} finally {
|
||||
_syncingFromController = false;
|
||||
@ -145,9 +150,7 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
|
||||
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(),
|
||||
installPath: '',
|
||||
extraConfigs: _extraRows
|
||||
.map(
|
||||
(row) => WorkspaceExtraConfig(
|
||||
@ -311,8 +314,8 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
|
||||
_field(
|
||||
width: 320,
|
||||
controller: _installPathController,
|
||||
enabled: !disabled,
|
||||
label: appText('安装路径', 'Install path'),
|
||||
enabled: false,
|
||||
label: appText('执行脚本', 'Setup script'),
|
||||
icon: Icons.storage_outlined,
|
||||
),
|
||||
_ExtraConfigEditor(
|
||||
@ -367,17 +370,16 @@ class _WorkspaceManagementFormState extends State<WorkspaceManagementForm> {
|
||||
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')),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
key: const Key('workspace-management-upgrade-button'),
|
||||
onPressed: disabled
|
||||
? null
|
||||
: () {
|
||||
_sync();
|
||||
widget.onUpgrade();
|
||||
},
|
||||
icon: const Icon(Icons.system_update_alt_outlined),
|
||||
label: Text(appText('升级工作空间', 'Upgrade workspace')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -438,7 +440,7 @@ class _ExtraConfigEditor extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
appText('额外配置', 'Extra configs'),
|
||||
appText('脚本参数', 'Script parameters'),
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
@ -569,7 +571,9 @@ class _ExtraRowControllers {
|
||||
|
||||
bool get isSensitiveKey {
|
||||
final key = keyController.text.trim().toUpperCase();
|
||||
return key.contains('KEY') || key.contains('TOKEN') || key.contains('SECRET');
|
||||
return key.contains('KEY') ||
|
||||
key.contains('TOKEN') ||
|
||||
key.contains('SECRET');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
|
||||
@ -62,7 +62,9 @@ class _WorkspaceManagementPanelState extends State<WorkspaceManagementPanel> {
|
||||
final connection = widget.appController.connection;
|
||||
if (connection.status == RuntimeConnectionStatus.connected) {
|
||||
final remote = connection.remoteAddress?.trim() ?? '';
|
||||
final parsed = Uri.tryParse(remote.contains('://') ? remote : 'https://$remote');
|
||||
final parsed = Uri.tryParse(
|
||||
remote.contains('://') ? remote : 'https://$remote',
|
||||
);
|
||||
if (parsed != null && parsed.host.trim().isNotEmpty) {
|
||||
return parsed.host.trim();
|
||||
}
|
||||
@ -104,7 +106,45 @@ class _WorkspaceManagementPanelState extends State<WorkspaceManagementPanel> {
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
unawaited(_controller.createWorkspace(installMissingPrerequisites: true));
|
||||
unawaited(_controller.createWorkspace());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmUpgrade() async {
|
||||
if (!_controller.canSubmit) {
|
||||
await _controller.upgradeWorkspace();
|
||||
return;
|
||||
}
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(appText('确认升级工作空间', 'Confirm workspace upgrade')),
|
||||
content: Text(
|
||||
appText(
|
||||
'即将在 ${_controller.serverAddress} 上执行远程 all-in-one 脚本升级 AI 工作空间。\n\n'
|
||||
'域名: ${_controller.workspaceDomain}\n'
|
||||
'SSH 用户: ${_controller.sshUsername}\n\n'
|
||||
'该操作会通过 curl 下载脚本并在远程主机执行,请确认这是你自己的服务器。',
|
||||
'XWorkmate will run the remote all-in-one script to upgrade the AI Workspace on ${_controller.serverAddress}.\n\n'
|
||||
'Domain: ${_controller.workspaceDomain}\n'
|
||||
'SSH user: ${_controller.sshUsername}\n\n'
|
||||
'This downloads the script with curl and runs it on the remote host. 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('确认升级', 'Upgrade')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
unawaited(_controller.upgradeWorkspace());
|
||||
}
|
||||
}
|
||||
|
||||
@ -234,8 +274,10 @@ class _WorkspaceManagementPanelState extends State<WorkspaceManagementPanel> {
|
||||
children: [
|
||||
WorkspaceManagementForm(
|
||||
controller: _controller,
|
||||
onDetect: () => unawaited(_controller.detectServer()),
|
||||
onDetect: () =>
|
||||
unawaited(_controller.detectServer()),
|
||||
onCreate: () => unawaited(_confirmCreate()),
|
||||
onUpgrade: () => unawaited(_confirmUpgrade()),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
WorkspaceManagementSteps(steps: _controller.steps),
|
||||
@ -275,9 +317,8 @@ class _LogPanel extends StatelessWidget {
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: const Key('workspace-management-log-toggle'),
|
||||
onPressed: () => controller.updateForm(
|
||||
logsExpanded: !controller.logsExpanded,
|
||||
),
|
||||
onPressed: () =>
|
||||
controller.updateForm(logsExpanded: !controller.logsExpanded),
|
||||
icon: Icon(
|
||||
controller.logsExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
),
|
||||
|
||||
@ -32,12 +32,40 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
String? sshKeyPath;
|
||||
int sshPort = 22;
|
||||
String? sudoPassword;
|
||||
String installPath = '/opt/xworkspace/playbooks';
|
||||
String installPath = '';
|
||||
final List<WorkspaceExtraConfig> extraConfigs = <WorkspaceExtraConfig>[
|
||||
WorkspaceExtraConfig(key: 'DEEPSEEK_API_KEY', value: '', note: ''),
|
||||
WorkspaceExtraConfig(key: 'NVIDIA_API_KEY', value: '', note: ''),
|
||||
WorkspaceExtraConfig(key: 'OLLAMA_API_KEY', value: '', note: ''),
|
||||
WorkspaceExtraConfig(key: 'OPENCLAW_GATEWAY_TOKEN', value: '', note: ''),
|
||||
WorkspaceExtraConfig(
|
||||
key: 'AI_WORKSPACE_SECURITY_LEVEL',
|
||||
value: 'strict',
|
||||
note: '',
|
||||
),
|
||||
WorkspaceExtraConfig(
|
||||
key: 'XWORKSPACE_CONSOLE_ENABLE_XRDP',
|
||||
value: 'true',
|
||||
note: '',
|
||||
),
|
||||
WorkspaceExtraConfig(
|
||||
key: 'XWORKSPACE_CONSOLE_PUBLIC_ACCESS',
|
||||
value: 'true',
|
||||
note: '',
|
||||
),
|
||||
WorkspaceExtraConfig(
|
||||
key: 'XWORKMATE_BRIDGE_PUBLIC_ACCESS',
|
||||
value: 'true',
|
||||
note: '',
|
||||
),
|
||||
WorkspaceExtraConfig(
|
||||
key: 'GATEWAY_OPENCLAW_PUBLIC_ACCESS',
|
||||
value: 'false',
|
||||
note: '',
|
||||
),
|
||||
WorkspaceExtraConfig(key: 'VAULT_PUBLIC_ACCESS', value: 'false', note: ''),
|
||||
WorkspaceExtraConfig(
|
||||
key: 'LITELLM_API_CADDY_STRICT_WHITELIST',
|
||||
value: 'true',
|
||||
note: '',
|
||||
),
|
||||
WorkspaceExtraConfig(key: 'TOKEN', value: '', note: ''),
|
||||
];
|
||||
bool showAdvanced = false;
|
||||
bool logsExpanded = false;
|
||||
@ -96,11 +124,9 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
_prepareRun(ProvisionPhase.checking);
|
||||
_setStep('ssh_connect', StepStatus.running, null);
|
||||
try {
|
||||
final detected = await ServerDetector(executor).detect(
|
||||
sshConfig(),
|
||||
workspaceDomain.trim(),
|
||||
bridgeDomain,
|
||||
);
|
||||
final detected = await ServerDetector(
|
||||
executor,
|
||||
).detect(sshConfig(), workspaceDomain.trim(), bridgeDomain);
|
||||
serverInfo = detected;
|
||||
_setStep('ssh_connect', StepStatus.success, null);
|
||||
final blockingIssue = validatePrecheckBlockingIssueFor(detected);
|
||||
@ -122,7 +148,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createWorkspace({bool installMissingPrerequisites = false}) async {
|
||||
Future<void> createWorkspace() async {
|
||||
if (!canSubmit) {
|
||||
_fail(WorkspaceProvisionValidationException());
|
||||
return;
|
||||
@ -130,11 +156,9 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
_prepareRun(ProvisionPhase.running, keepDetection: true);
|
||||
try {
|
||||
if (serverInfo == null) {
|
||||
final detected = await ServerDetector(executor).detect(
|
||||
sshConfig(),
|
||||
workspaceDomain.trim(),
|
||||
bridgeDomain,
|
||||
);
|
||||
final detected = await ServerDetector(
|
||||
executor,
|
||||
).detect(sshConfig(), workspaceDomain.trim(), bridgeDomain);
|
||||
serverInfo = detected;
|
||||
}
|
||||
final blockingIssue = validatePrecheckBlockingIssue();
|
||||
@ -154,15 +178,14 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
bridgeDomain: bridgeDomain,
|
||||
bridgeToken: bridgeToken,
|
||||
extraConfigs: extraConfigs,
|
||||
installPath: installPath.trim(),
|
||||
installMissingPrerequisites: installMissingPrerequisites,
|
||||
serverInfo: serverInfo,
|
||||
onStepUpdate: _setStep,
|
||||
onLog: _appendLog,
|
||||
);
|
||||
await _verifyDeploymentReadiness();
|
||||
for (final step in steps) {
|
||||
if (step.status == StepStatus.pending || step.status == StepStatus.running) {
|
||||
if (step.status == StepStatus.pending ||
|
||||
step.status == StepStatus.running) {
|
||||
_setStep(step.id, StepStatus.success, null);
|
||||
}
|
||||
}
|
||||
@ -180,14 +203,57 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> upgradeWorkspace() async {
|
||||
_fail(
|
||||
PlaybookRunException(
|
||||
appText(
|
||||
'升级功能等待 playbooks 仓库提供 upgrade-ai-workspace.yml 后启用。',
|
||||
'Upgrade waits for upgrade-ai-workspace.yml in the playbooks repository.',
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!canSubmit) {
|
||||
_fail(WorkspaceProvisionValidationException());
|
||||
return;
|
||||
}
|
||||
_prepareRun(ProvisionPhase.running, keepDetection: true);
|
||||
try {
|
||||
if (serverInfo == null) {
|
||||
final detected = await ServerDetector(
|
||||
executor,
|
||||
).detect(sshConfig(), workspaceDomain.trim(), bridgeDomain);
|
||||
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: 'upgrade',
|
||||
workspaceDomain: workspaceDomain.trim(),
|
||||
bridgeDomain: bridgeDomain,
|
||||
bridgeToken: bridgeToken,
|
||||
extraConfigs: extraConfigs,
|
||||
serverInfo: serverInfo,
|
||||
onStepUpdate: _setStep,
|
||||
onLog: _appendLog,
|
||||
);
|
||||
await _verifyDeploymentReadiness();
|
||||
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: bridgeBaseUrl,
|
||||
bridgeToken: bridgeToken,
|
||||
);
|
||||
errorMessage = null;
|
||||
_appendLog(appText('工作空间升级完成。', 'Workspace upgrade completed.'));
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
_fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
@ -243,7 +309,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
MapEntry('ssh_username', sshUsername.trim()),
|
||||
MapEntry('auth_method', authMethod.name),
|
||||
MapEntry('ssh_port', sshPort),
|
||||
MapEntry('install_path', installPath.trim()),
|
||||
MapEntry('setup_script_url', PlaybookRunner.setupScriptUrl),
|
||||
MapEntry('show_advanced', showAdvanced),
|
||||
MapEntry('logs_expanded', logsExpanded),
|
||||
MapEntry('ssh_password', redact(sshPassword)),
|
||||
@ -421,8 +487,12 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
for (final service in services) {
|
||||
final unit = shellQuote('$service.service');
|
||||
final envKey = service.toUpperCase().replaceAll('-', '_');
|
||||
buffer.writeln('if systemctl list-unit-files $unit >/dev/null 2>&1 || systemctl status $unit >/dev/null 2>&1; then');
|
||||
buffer.writeln(' STATE=\$(systemctl is-active $unit 2>/dev/null || echo inactive)');
|
||||
buffer.writeln(
|
||||
'if systemctl list-unit-files $unit >/dev/null 2>&1 || systemctl status $unit >/dev/null 2>&1; then',
|
||||
);
|
||||
buffer.writeln(
|
||||
' STATE=\$(systemctl is-active $unit 2>/dev/null || echo inactive)',
|
||||
);
|
||||
buffer.writeln(' echo SERVICE_$envKey=\$STATE');
|
||||
buffer.writeln('fi');
|
||||
}
|
||||
@ -493,7 +563,10 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
if (text.isEmpty) {
|
||||
return '""';
|
||||
}
|
||||
if (text == redactedValue || text.contains(RegExp(r'[:#\n\r\t]')) || text.startsWith(' ') || text.endsWith(' ')) {
|
||||
if (text == redactedValue ||
|
||||
text.contains(RegExp(r'[:#\n\r\t]')) ||
|
||||
text.startsWith(' ') ||
|
||||
text.endsWith(' ')) {
|
||||
return '"${text.replaceAll('"', '\\"')}"';
|
||||
}
|
||||
return text;
|
||||
@ -544,9 +617,7 @@ class WorkspaceProvisionController extends ChangeNotifier {
|
||||
Object? value,
|
||||
List<WorkspaceExtraConfig> current,
|
||||
) {
|
||||
final existing = {
|
||||
for (final config in current) config.key.trim(): config,
|
||||
};
|
||||
final existing = {for (final config in current) config.key.trim(): config};
|
||||
final parsed = <WorkspaceExtraConfig>[];
|
||||
if (value is YamlList) {
|
||||
for (final item in value) {
|
||||
|
||||
@ -78,16 +78,37 @@ BRIDGE_PORT_443_OPEN=yes
|
||||
expect(info.displaySummary, contains('桥接 443 端口当前空闲'));
|
||||
});
|
||||
|
||||
test('ansible parser maps human readable output to step events', () {
|
||||
final parser = AnsibleOutputParser();
|
||||
test('setup script command passes env to remote bash', () {
|
||||
final command = PlaybookRunner.setupScriptCommand(
|
||||
ssh: const SshConfig(
|
||||
host: '203.0.113.10',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authMethod: AuthMethod.sshKey,
|
||||
),
|
||||
action: 'create',
|
||||
workspaceDomain: 'workspace.example.com',
|
||||
bridgeDomain: 'xworkmate-bridge.workspace.example.com',
|
||||
bridgeToken: "tok'en",
|
||||
extraConfigs: [
|
||||
WorkspaceExtraConfig(
|
||||
key: 'AI_WORKSPACE_SECURITY_LEVEL',
|
||||
value: 'strict',
|
||||
),
|
||||
WorkspaceExtraConfig(
|
||||
key: 'XWORKSPACE_CONSOLE_ENABLE_XRDP',
|
||||
value: 'true',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
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);
|
||||
expect(command, contains('curl -sfL'));
|
||||
expect(command, contains(PlaybookRunner.setupScriptUrl));
|
||||
expect(command, contains('AI_WORKSPACE_SECURITY_LEVEL='));
|
||||
expect(command, contains('XWORKSPACE_CONSOLE_ENABLE_XRDP='));
|
||||
expect(command, contains('TOKEN='));
|
||||
expect(command, contains("'tok'\"'\"'en'"));
|
||||
expect(command, contains('bash -lc'));
|
||||
});
|
||||
|
||||
test('detection command quotes workspace domain', () {
|
||||
@ -103,7 +124,9 @@ BRIDGE_PORT_443_OPEN=yes
|
||||
|
||||
test('bridge domain uses user input when already a bridge host', () {
|
||||
expect(
|
||||
WorkspaceProvisionController.deriveBridgeDomain('acp-bridge.onwalk.net'),
|
||||
WorkspaceProvisionController.deriveBridgeDomain(
|
||||
'acp-bridge.onwalk.net',
|
||||
),
|
||||
'acp-bridge.onwalk.net',
|
||||
);
|
||||
});
|
||||
@ -190,11 +213,10 @@ BRIDGE_PORT_443_OPEN=yes
|
||||
);
|
||||
});
|
||||
|
||||
test('createWorkspace runs playbook flow with fake SSH', () async {
|
||||
test('createWorkspace runs remote setup script with fake SSH', () async {
|
||||
final executor = _FakeSshExecutor(
|
||||
commandResults: [
|
||||
const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''),
|
||||
const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''),
|
||||
const SshResult(exitCode: 0, stdout: 'curl ready', stderr: ''),
|
||||
const SshResult(
|
||||
exitCode: 0,
|
||||
stdout: '''
|
||||
@ -207,8 +229,8 @@ SERVICE_HERMES_GATEWAY=active
|
||||
),
|
||||
],
|
||||
streamingChunks: [
|
||||
'TASK [Install desktop packages]\nok: [localhost]\n',
|
||||
'TASK [Configure caddy TLS]\nchanged: [localhost]\n',
|
||||
'Installing desktop packages\n',
|
||||
'Configuring caddy TLS\n',
|
||||
],
|
||||
);
|
||||
final controller = WorkspaceProvisionController(
|
||||
@ -250,11 +272,21 @@ SERVICE_HERMES_GATEWAY=active
|
||||
'https://xworkmate-bridge.workspace.example.com',
|
||||
);
|
||||
expect(controller.deploymentResult?.bridgeToken, isNotEmpty);
|
||||
expect(executor.commands.join('\n'), contains('ansible-playbook'));
|
||||
expect(executor.commands.join('\n'), contains('curl -sfL'));
|
||||
expect(
|
||||
executor.commands.join('\n'),
|
||||
contains('setup-ai-workspace-all-in-one.sh'),
|
||||
);
|
||||
expect(
|
||||
executor.commands.join('\n'),
|
||||
contains('AI_WORKSPACE_SECURITY_LEVEL='),
|
||||
);
|
||||
});
|
||||
|
||||
test('precheck does not block when 443 is not open', () async {
|
||||
final controller = WorkspaceProvisionController(executor: _FakeSshExecutor());
|
||||
final controller = WorkspaceProvisionController(
|
||||
executor: _FakeSshExecutor(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
controller.updateForm(
|
||||
serverAddress: '203.0.113.10',
|
||||
@ -286,7 +318,9 @@ SERVICE_HERMES_GATEWAY=active
|
||||
});
|
||||
|
||||
test('precheck blocks when bridge DNS is missing', () async {
|
||||
final controller = WorkspaceProvisionController(executor: _FakeSshExecutor());
|
||||
final controller = WorkspaceProvisionController(
|
||||
executor: _FakeSshExecutor(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
controller.updateForm(
|
||||
serverAddress: '203.0.113.10',
|
||||
@ -314,14 +348,13 @@ SERVICE_HERMES_GATEWAY=active
|
||||
bridgePort443Open: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.validatePrecheckBlockingIssue(),
|
||||
contains('A 记录'),
|
||||
);
|
||||
expect(controller.validatePrecheckBlockingIssue(), contains('A 记录'));
|
||||
});
|
||||
|
||||
test('precheck allows debian family systems', () async {
|
||||
final controller = WorkspaceProvisionController(executor: _FakeSshExecutor());
|
||||
final controller = WorkspaceProvisionController(
|
||||
executor: _FakeSshExecutor(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
controller.updateForm(
|
||||
serverAddress: '203.0.113.10',
|
||||
@ -352,18 +385,20 @@ SERVICE_HERMES_GATEWAY=active
|
||||
expect(controller.validatePrecheckBlockingIssue(), isNull);
|
||||
});
|
||||
|
||||
test('import yaml restores editable state without leaking redacted values', () {
|
||||
final controller = WorkspaceProvisionController();
|
||||
addTearDown(controller.dispose);
|
||||
controller.updateForm(
|
||||
serverAddress: 'old.example.com',
|
||||
workspaceDomain: 'old.net',
|
||||
sshUsername: 'root',
|
||||
sshPassword: 'keep-secret',
|
||||
showAdvanced: false,
|
||||
);
|
||||
test(
|
||||
'import yaml restores editable state without leaking redacted values',
|
||||
() {
|
||||
final controller = WorkspaceProvisionController();
|
||||
addTearDown(controller.dispose);
|
||||
controller.updateForm(
|
||||
serverAddress: 'old.example.com',
|
||||
workspaceDomain: 'old.net',
|
||||
sshUsername: 'root',
|
||||
sshPassword: 'keep-secret',
|
||||
showAdvanced: false,
|
||||
);
|
||||
|
||||
controller.importYaml('''
|
||||
controller.importYaml('''
|
||||
server_address: 167.179.110.129
|
||||
workspace_domain: onwalk.net
|
||||
ssh_username: root
|
||||
@ -382,69 +417,70 @@ extra_configs:
|
||||
note: "OpenClaw"
|
||||
''');
|
||||
|
||||
expect(controller.serverAddress, '167.179.110.129');
|
||||
expect(controller.workspaceDomain, 'onwalk.net');
|
||||
expect(controller.showAdvanced, isTrue);
|
||||
expect(controller.sshPassword, 'keep-secret');
|
||||
expect(controller.extraConfigs.first.value, 'deepseek-new');
|
||||
expect(controller.extraConfigs.last.value, '');
|
||||
});
|
||||
expect(controller.serverAddress, '167.179.110.129');
|
||||
expect(controller.workspaceDomain, 'onwalk.net');
|
||||
expect(controller.showAdvanced, isTrue);
|
||||
expect(controller.sshPassword, 'keep-secret');
|
||||
expect(controller.extraConfigs.first.value, 'deepseek-new');
|
||||
expect(controller.extraConfigs.last.value, '');
|
||||
},
|
||||
);
|
||||
|
||||
test('post deploy verification fails when external probe does not connect', () async {
|
||||
final controller = WorkspaceProvisionController(
|
||||
executor: _FakeSshExecutor(
|
||||
commandResults: [
|
||||
const SshResult(exitCode: 0, stdout: 'pulled', stderr: ''),
|
||||
const SshResult(exitCode: 0, stdout: 'wrote', stderr: ''),
|
||||
const SshResult(
|
||||
exitCode: 0,
|
||||
stdout: '''
|
||||
test(
|
||||
'post deploy verification fails when external probe does not connect',
|
||||
() async {
|
||||
final controller = WorkspaceProvisionController(
|
||||
executor: _FakeSshExecutor(
|
||||
commandResults: [
|
||||
const SshResult(exitCode: 0, stdout: 'curl ready', stderr: ''),
|
||||
const SshResult(
|
||||
exitCode: 0,
|
||||
stdout: '''
|
||||
SERVICE_CADDY=active
|
||||
SERVICE_XWORKMATE_BRIDGE=active
|
||||
''',
|
||||
stderr: '',
|
||||
),
|
||||
],
|
||||
streamingChunks: [
|
||||
'TASK [Configure caddy TLS]\nchanged: [localhost]\n',
|
||||
],
|
||||
),
|
||||
externalPortProbe: (host) async {
|
||||
throw PlaybookRunException('probe failed for $host');
|
||||
},
|
||||
);
|
||||
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,
|
||||
port80ListenerCount: 0,
|
||||
port80Open: true,
|
||||
port443ListenerCount: 0,
|
||||
port443Open: true,
|
||||
bridgeDnsAddressCount: 1,
|
||||
bridgePort80ListenerCount: 0,
|
||||
bridgePort80Open: true,
|
||||
bridgePort443ListenerCount: 0,
|
||||
bridgePort443Open: true,
|
||||
);
|
||||
stderr: '',
|
||||
),
|
||||
],
|
||||
streamingChunks: ['Configuring caddy TLS\n'],
|
||||
),
|
||||
externalPortProbe: (host) async {
|
||||
throw PlaybookRunException('probe failed for $host');
|
||||
},
|
||||
);
|
||||
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,
|
||||
port80ListenerCount: 0,
|
||||
port80Open: true,
|
||||
port443ListenerCount: 0,
|
||||
port443Open: true,
|
||||
bridgeDnsAddressCount: 1,
|
||||
bridgePort80ListenerCount: 0,
|
||||
bridgePort80Open: true,
|
||||
bridgePort443ListenerCount: 0,
|
||||
bridgePort443Open: true,
|
||||
);
|
||||
|
||||
await controller.createWorkspace();
|
||||
await controller.createWorkspace();
|
||||
|
||||
expect(controller.phase, ProvisionPhase.failed);
|
||||
expect(controller.errorMessage, contains('probe failed'));
|
||||
});
|
||||
expect(controller.phase, ProvisionPhase.failed);
|
||||
expect(controller.errorMessage, contains('probe failed'));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ 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', (
|
||||
testWidgets('panel renders form controls and enables upgrade', (
|
||||
tester,
|
||||
) async {
|
||||
final appController = _NoopAppController(store: _MemorySecureConfigStore());
|
||||
@ -44,13 +44,13 @@ void main() {
|
||||
find.byKey(const Key('workspace-management-upgrade-button')),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('高级选项'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('安装路径'), findsOneWidget);
|
||||
expect(find.text('执行脚本'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('panel switches auth method and expands logs', (tester) async {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user