From fa9a2e58099dd73a2ea5e610e5c3aa987f796db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E9=B8=BF=E5=AE=87?= Date: Mon, 9 Jun 2025 19:46:59 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(deploy):=20=E6=94=AF=E6=8C=81=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 史鸿宇 --- .../main/deploy/core/DeploymentService.ts | 158 +++++++++++++++--- electron/welcome/timeLine.vue | 9 + 2 files changed, 140 insertions(+), 27 deletions(-) diff --git a/electron/main/deploy/core/DeploymentService.ts b/electron/main/deploy/core/DeploymentService.ts index d24c8e5..dbe7c13 100644 --- a/electron/main/deploy/core/DeploymentService.ts +++ b/electron/main/deploy/core/DeploymentService.ts @@ -11,7 +11,6 @@ import * as path from 'path'; import * as fs from 'fs'; import { exec } from 'child_process'; -import { promisify } from 'util'; import { getCachePath } from '../../common/cache-conf'; import type { DeploymentParams, @@ -23,7 +22,35 @@ import { } from './EnvironmentChecker'; import { ValuesYamlManager } from './ValuesYamlManager'; -const execAsync = promisify(exec); +/** + * 支持中断的异步执行函数 + */ +const execAsyncWithAbort = ( + command: string, + options: any = {}, + abortSignal?: AbortSignal, +): Promise<{ stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + const childProcess = exec(command, options, (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve({ + stdout: typeof stdout === 'string' ? stdout : stdout.toString(), + stderr: typeof stderr === 'string' ? stderr : stderr.toString(), + }); + } + }); + + // 如果提供了中断信号,监听中断事件 + if (abortSignal) { + abortSignal.addEventListener('abort', () => { + childProcess.kill('SIGTERM'); + reject(new Error('部署进程已被用户停止')); + }); + } + }); +}; /** * 部署服务核心类 @@ -40,6 +67,8 @@ export class DeploymentService { currentStep: 'idle', }; private statusCallback?: (status: DeploymentStatus) => void; + private abortController?: AbortController; + private currentProcess?: any; constructor() { this.cachePath = getCachePath(); @@ -94,6 +123,9 @@ export class DeploymentService { */ async startDeployment(params: DeploymentParams): Promise { try { + // 创建新的 AbortController 用于控制部署流程 + this.abortController = new AbortController(); + // 第一阶段:准备安装环境 this.updateStatus({ status: 'preparing', @@ -128,12 +160,25 @@ export class DeploymentService { currentStep: 'completed', }); } catch (error) { - this.updateStatus({ - status: 'error', - message: `部署失败: ${error instanceof Error ? error.message : String(error)}`, - currentStep: 'failed', - }); - throw error; + // 如果是因为手动停止导致的错误,使用停止状态 + if (this.abortController?.signal.aborted) { + this.updateStatus({ + status: 'idle', + message: '部署已停止', + currentStep: 'stopped', + }); + } else { + this.updateStatus({ + status: 'error', + message: `部署失败: ${error instanceof Error ? error.message : String(error)}`, + currentStep: 'failed', + }); + throw error; + } + } finally { + // 清理资源 + this.abortController = undefined; + this.currentProcess = undefined; } } @@ -203,7 +248,11 @@ export class DeploymentService { const gitDir = path.join(this.deploymentPath, '.git'); if (fs.existsSync(gitDir)) { // 已存在,执行 git pull 更新 - await execAsync('git pull origin master', { cwd: this.deploymentPath }); + await execAsyncWithAbort( + 'git pull origin master', + { cwd: this.deploymentPath }, + this.abortController?.signal, + ); this.updateStatus({ message: '更新部署仓库完成', currentStep: 'preparing-environment', @@ -211,11 +260,12 @@ export class DeploymentService { } else { // 不存在,克隆仓库 const repoUrl = 'https://gitee.com/openeuler/euler-copilot-framework.git'; - await execAsync( + await execAsyncWithAbort( `git clone ${repoUrl} ${path.basename(this.deploymentPath)}`, { cwd: deploymentParentDir, }, + this.abortController?.signal, ); this.updateStatus({ message: '克隆部署仓库完成', @@ -277,10 +327,14 @@ export class DeploymentService { const command = this.buildRootCommand(toolsScriptPath); // 执行脚本 - await execAsync(command, { - cwd: scriptsPath, - timeout: 300000, // 5分钟超时 - }); + await execAsyncWithAbort( + command, + { + cwd: scriptsPath, + timeout: 300000, // 5分钟超时 + }, + this.abortController?.signal, + ); } this.updateStatus({ @@ -361,11 +415,15 @@ export class DeploymentService { ); // 给脚本添加执行权限并执行 - await execAsync(command, { - cwd: scriptsPath, - timeout: 300000, // 5分钟超时 - env: execEnv, - }); + await execAsyncWithAbort( + command, + { + cwd: scriptsPath, + timeout: 300000, // 5分钟超时 + env: execEnv, + }, + this.abortController?.signal, + ); // 更新完成状态 this.updateStatus({ @@ -386,7 +444,11 @@ export class DeploymentService { try { // 检查当前用户 ID,0 表示 root - const { stdout } = await execAsync('id -u'); + const { stdout } = await execAsyncWithAbort( + 'id -u', + {}, + this.abortController?.signal, + ); const uid = parseInt(stdout.trim(), 10); // 如果是 root 用户,直接通过 @@ -397,7 +459,11 @@ export class DeploymentService { // 如果不是 root 用户,检查是否有 sudo 权限 try { // 检查用户是否在管理员组中(sudo、wheel、admin) - const { stdout: groupsOutput } = await execAsync('groups'); + const { stdout: groupsOutput } = await execAsyncWithAbort( + 'groups', + {}, + this.abortController?.signal, + ); const userGroups = groupsOutput.trim().split(/\s+/); // 检查常见的管理员组 @@ -414,7 +480,11 @@ export class DeploymentService { // 如果不在管理员组中,尝试检查是否有无密码 sudo 权限 try { - await execAsync('sudo -n true', { timeout: 3000 }); + await execAsyncWithAbort( + 'sudo -n true', + { timeout: 3000 }, + this.abortController?.signal, + ); // 如果成功,说明用户有无密码 sudo 权限 return; } catch { @@ -505,11 +575,45 @@ export class DeploymentService { * 停止部署 */ async stopDeployment(): Promise { - this.updateStatus({ - status: 'idle', - message: '部署已停止', - currentStep: 'stopped', - }); + try { + // 如果有正在进行的部署流程,中断它 + if (this.abortController && !this.abortController.signal.aborted) { + console.log('正在停止部署流程...'); + + // 发送中断信号 + this.abortController.abort(); + console.log('已发送中断信号给所有正在运行的进程'); + + // 等待一小段时间确保进程能够响应中断信号 + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log('等待进程响应中断信号完成'); + + console.log('部署流程已成功停止'); + } else { + console.log('没有正在进行的部署流程,直接更新为停止状态'); + } + + // 统一更新为停止状态,不使用前端无法识别的 'stopping' 状态 + this.updateStatus({ + status: 'idle', + message: '部署已停止', + currentStep: 'stopped', + }); + } catch (error) { + console.error('停止部署时出错:', error); + + // 即使停止过程出错,也要更新状态 + this.updateStatus({ + status: 'idle', + message: '部署已停止', + currentStep: 'stopped', + }); + } finally { + // 清理资源 + console.log('清理部署相关资源'); + this.abortController = undefined; + this.currentProcess = undefined; + } } /** diff --git a/electron/welcome/timeLine.vue b/electron/welcome/timeLine.vue index fb65ee5..ce75060 100644 --- a/electron/welcome/timeLine.vue +++ b/electron/welcome/timeLine.vue @@ -244,6 +244,15 @@ const handleStop = async () => { } } catch (error) { console.error('停止部署失败:', error); + + // 即使停止失败,也要更新界面显示错误状态 + const runningActivityIndex = activities.value.findIndex( + (activity) => activity.type === 'running', + ); + + if (runningActivityIndex !== -1) { + activities.value[runningActivityIndex].type = 'failed'; + } } }; -- Gitee From d012109166f5fd1f307aa017f9942681a56e79e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E9=B8=BF=E5=AE=87?= Date: Mon, 9 Jun 2025 20:32:04 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(deploy):=20=E5=A2=9E=E5=BC=BA=20Linux?= =?UTF-8?q?=20=E7=B3=BB=E7=BB=9F=E4=B8=8A=E7=9A=84=20sudo=20=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=AE=A1=E7=90=86=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E6=80=A7=E8=8E=B7=E5=8F=96=E5=92=8C=E5=88=B7=E6=96=B0?= =?UTF-8?q?=20sudo=20=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 史鸿宇 --- .../main/deploy/core/DeploymentService.ts | 136 +++++++++++++++--- 1 file changed, 113 insertions(+), 23 deletions(-) diff --git a/electron/main/deploy/core/DeploymentService.ts b/electron/main/deploy/core/DeploymentService.ts index dbe7c13..4849750 100644 --- a/electron/main/deploy/core/DeploymentService.ts +++ b/electron/main/deploy/core/DeploymentService.ts @@ -69,6 +69,7 @@ export class DeploymentService { private statusCallback?: (status: DeploymentStatus) => void; private abortController?: AbortController; private currentProcess?: any; + private sudoSessionActive: boolean = false; constructor() { this.cachePath = getCachePath(); @@ -136,13 +137,16 @@ export class DeploymentService { // 1. 检查环境 await this.checkEnvironment(); - // 2. 克隆仓库 + // 2. 在Linux系统上,一次性获取sudo权限并保持会话 + await this.initializeSudoSession(); + + // 3. 克隆仓库 await this.cloneRepository(); - // 3. 配置 values.yaml + // 4. 配置 values.yaml await this.configureValues(params); - // 4. 执行部署脚本中的工具安装部分(如果需要) + // 5. 执行部署脚本中的工具安装部分(如果需要) await this.installTools(); // 更新准备环境完成状态 @@ -179,6 +183,7 @@ export class DeploymentService { // 清理资源 this.abortController = undefined; this.currentProcess = undefined; + this.sudoSessionActive = false; // 重置sudo会话状态 } } @@ -323,7 +328,7 @@ export class DeploymentService { // 检查脚本文件是否存在 if (fs.existsSync(toolsScriptPath)) { - // 构建需要权限的命令 + // 构建需要权限的命令,使用已建立的sudo会话 const command = this.buildRootCommand(toolsScriptPath); // 执行脚本 @@ -401,6 +406,11 @@ export class DeploymentService { throw new Error(`脚本文件不存在: ${scriptPath}`); } + // 在执行脚本前刷新sudo会话(除第一个脚本外) + if (i > 0) { + await this.refreshSudoSession(); + } + // 准备环境变量 const execEnv = { ...process.env, @@ -517,21 +527,87 @@ export class DeploymentService { } /** - * 构建需要 root 权限的命令 + * 初始化sudo会话,一次性获取权限并保持会话 */ - private buildRootCommand( - scriptPath: string, - useInputRedirection?: boolean, - inputData?: string, - ): string { - // 在 Linux 系统上,如果不是 root 用户,使用图形化 sudo 工具 - const needsSudo = - process.platform === 'linux' && process.getuid && process.getuid() !== 0; + private async initializeSudoSession(): Promise { + // 只在 Linux 系统上需要sudo会话 + if (process.platform !== 'linux') { + return; + } + + // 检查是否为root用户,如果是则不需要sudo + if (process.getuid && process.getuid() === 0) { + this.sudoSessionActive = true; + return; + } + + this.updateStatus({ + status: 'preparing', + message: '获取管理员权限...', + currentStep: 'preparing-environment', + }); + + try { + // 使用pkexec或其他图形化sudo工具一次性获取权限 + // 这里执行一个简单的sudo命令来激活会话 + const sudoCommand = this.getSudoCommand(); + await execAsyncWithAbort( + `${sudoCommand}true`, + { timeout: 30000 }, // 30秒超时,给用户足够时间输入密码 + this.abortController?.signal, + ); + + this.sudoSessionActive = true; + + this.updateStatus({ + message: '管理员权限获取成功', + currentStep: 'preparing-environment', + }); + } catch (error) { + throw new Error( + `获取管理员权限失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * 刷新sudo会话时间戳,延长会话时间 + */ + private async refreshSudoSession(): Promise { + if (process.platform !== 'linux' || !this.sudoSessionActive) { + return; + } + + // 检查是否为root用户 + if (process.getuid && process.getuid() === 0) { + return; + } + + try { + // 使用 sudo -v 刷新时间戳,无需重新输入密码 + await execAsyncWithAbort( + 'sudo -v', + { timeout: 5000 }, + this.abortController?.signal, + ); + } catch (error) { + // 如果刷新失败,可能需要重新获取权限 + console.warn('刷新sudo会话失败,可能需要重新输入密码:', error); + this.sudoSessionActive = false; + } + } - // 获取合适的图形化 sudo 工具 - const getSudoCommand = (): string => { - if (!needsSudo) return ''; + /** + * 获取合适的sudo命令前缀 + */ + private getSudoCommand(): string { + // 检查是否为root用户 + if (process.getuid && process.getuid() === 0) { + return ''; + } + // 在Linux系统上使用图形化sudo工具 + if (process.platform === 'linux') { // 构建完整的环境变量,确保 PATH 包含常用的系统路径 const currentPath = process.env.PATH || ''; const additionalPaths = [ @@ -554,18 +630,32 @@ export class DeploymentService { // 优先使用 pkexec(现代 Linux 桌面环境的标准) // 传递必要的环境变量,包括完整的 PATH return `pkexec env DISPLAY=$DISPLAY XAUTHORITY=$XAUTHORITY PATH="${fullPath}" `; - }; + } + + return ''; + } + + /** + * 构建需要 root 权限的命令(优化版本,减少密码输入) + */ + private buildRootCommand( + scriptPath: string, + useInputRedirection?: boolean, + inputData?: string, + ): string { + // 获取sudo命令前缀 + const sudoCommand = this.getSudoCommand(); - const sudoCommand = getSudoCommand(); + let command = ''; - let command = sudoCommand; - command += `chmod +x "${scriptPath}" && `; - command += sudoCommand; + // 给脚本添加执行权限 + command += `${sudoCommand}chmod +x "${scriptPath}"`; + // 执行脚本 if (useInputRedirection && inputData) { - command += `bash -c 'echo "${inputData}" | bash "${scriptPath}"'`; + command += ` && ${sudoCommand}bash -c 'echo "${inputData}" | bash "${scriptPath}"'`; } else { - command += `bash "${scriptPath}"`; + command += ` && ${sudoCommand}bash "${scriptPath}"`; } return command; -- Gitee From e8ad1d2e680e3923cf2d0283220757e99660c98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E9=B8=BF=E5=AE=87?= Date: Mon, 9 Jun 2025 20:59:22 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(i18n):=20=E6=9B=B4=E6=96=B0=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E5=92=8C=E4=B8=AD=E6=96=87=E8=AF=AD=E8=A8=80=E5=8C=85?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E6=8F=90=E7=A4=BA=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 史鸿宇 --- electron/welcome/lang/en.ts | 10 +++++----- electron/welcome/lang/zh.ts | 16 ++++++++-------- electron/welcome/localDeploy.vue | 5 ++++- electron/welcome/timeLine.vue | 2 +- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/electron/welcome/lang/en.ts b/electron/welcome/lang/en.ts index b87e359..4e11c32 100644 --- a/electron/welcome/lang/en.ts +++ b/electron/welcome/lang/en.ts @@ -7,20 +7,20 @@ export default { confirm: 'Ok', pleaseInput: 'Please Input', validUrl: 'Please enter a valid URL', - validationFailure:'Validation failure', -}, + validationFailure: 'Validation failure', + }, localDeploy: { model: 'Large model', embeddingModel: 'Embedding Model', url: 'URL', - modelName: 'ModelName', - apiKey: 'API_Key', + modelName: 'Model Name', + apiKey: 'API Key', copyTip: 'Reuse the same link for large models', installation: 'Installation', + prepareEnv: 'Preparing installation environment', dataBase: 'Database services', authHub: 'AuthHub services', intelligence: 'Intelligence services', - serviceLaunch: 'Configuration File Initialization & Service Startup', stopInstall: 'Stop installation', complete: 'Complete', retry: 'Retry', diff --git a/electron/welcome/lang/zh.ts b/electron/welcome/lang/zh.ts index 9bd17c7..8ee6c69 100644 --- a/electron/welcome/lang/zh.ts +++ b/electron/welcome/lang/zh.ts @@ -6,21 +6,21 @@ export default { back: '返回', confirm: '确定', pleaseInput: '请输入', - validUrl: '请输入有效的URL', - validationFailure:'检验失败', -}, + validUrl: '请输入有效的 URL', + validationFailure: '检验失败', + }, localDeploy: { model: '大模型', - embeddingModel: 'Embedding模型', + embeddingModel: 'Embedding 模型', url: 'URL', modelName: '模型名称', - apiKey: 'API_Key', + apiKey: 'API Key', copyTip: '复用大模型相同链接', installation: '安装中', + prepareEnv: '准备安装环境', dataBase: '数据库服务', - authHub: 'AuthHub服务', - intelligence: 'Intelligence服务', - serviceLaunch: '配置文件初始化 & 服务启动', + authHub: 'AuthHub 服务', + intelligence: 'Intelligence 服务', stopInstall: '停止安装', complete: '完成', retry: '重试', diff --git a/electron/welcome/localDeploy.vue b/electron/welcome/localDeploy.vue index cf76587..e1d6979 100644 --- a/electron/welcome/localDeploy.vue +++ b/electron/welcome/localDeploy.vue @@ -71,7 +71,10 @@ prop="url" label-position="left" > - +