From 304f7d3f825c13eda3743d4a16e2f0c940ddcddc Mon Sep 17 00:00:00 2001 From: hwy <1093970372@qq.com> Date: Sun, 4 Jan 2026 22:30:17 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=BC=80=E5=8F=91=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E5=88=9B=E5=BB=BA=E8=BF=87=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- html/src/components/terminal/index.tsx | 312 +++++++++++++++----- html/src/components/terminal/xterm/index.ts | 134 +++++---- 2 files changed, 314 insertions(+), 132 deletions(-) diff --git a/html/src/components/terminal/index.tsx b/html/src/components/terminal/index.tsx index e924ce4..1005603 100644 --- a/html/src/components/terminal/index.tsx +++ b/html/src/components/terminal/index.tsx @@ -16,22 +16,15 @@ interface State { export class Terminal extends Component { private container: HTMLElement; private xterm: Xterm; - private intervalID: NodeJS.Timeout; + private pollingIntervalID: NodeJS.Timeout | null = null; private currentDevcontainer = { - title: 'Devcontainer Info', - detail: 'No Devcontainer Created yet', - port: '', - ip:'', - steps: [ - // { - // summary: '', - // duration: '', - // status: '', - // logs:{ - // }, - // } - ], + state: '-1', // 容器状态 (0-5, -1) + steps: [] as any[], }; + private isDockerType = false; + private apiBaseUrl = ''; + private apiParams = new URLSearchParams(); + constructor(props: Props) { super(); this.xterm = new Xterm(props, this.showModal); @@ -41,44 +34,19 @@ export class Terminal extends Component { await this.xterm.refreshToken(); const options = new URLSearchParams(decodeURIComponent(window.location.search)); - const params = new URLSearchParams({ + this.isDockerType = options.get('type') === 'docker'; + this.apiBaseUrl = `http://${options.get('domain')}:${options.get('port')}/${options.get('user')}/${options.get('repo')}`; + this.apiParams = new URLSearchParams({ repo: options.get('repoid') as string, user: options.get('userid') as string, }); - fetch('http://' + - options.get('domain') + - ':'+ - options.get('port') + - '/' + - options.get('user') + - '/' + - options.get('repo') + - '/devcontainer/status?' + - params - ) - .then(response => response.json()) - .then(data => { - if (data.status !== '-1') { - if (options.get('type') === 'docker') { - this.xterm.open(this.container); - this.xterm.changeContainerStatus(data.status); - this.xterm.connect(); - } else { - this.intervalID = setInterval(this.loadOutput, 8000); - this.xterm.open(this.container); - this.xterm.changeUrl(this.currentDevcontainer.ip, this.currentDevcontainer.port) - this.xterm.changeStatus(true); - this.xterm.connect(); - } - } - }) - .catch(error => { - console.error('Error:', error); - }); + // 首次检查状态,根据状态启动相应的轮询策略 + await this.checkStatusAndStartPolling(); } componentWillUnmount() { + this.stopPolling(); this.xterm.dispose(); } @@ -107,47 +75,237 @@ export class Terminal extends Component { if (files) this.xterm.sendFile(files); } + /** + * 首次状态检查并启动相应的轮询策略 + * + * 流程: + * 1. 状态 0-4: 启动日志轮询,显示容器创建日志 + * - 日志轮询会在状态变为 5 时自动停止并获取连接命令 + * - 或者检测到 DEVCONTAINER_STARTUP_COMPLETE 后主动检查状态并获取连接命令 + * 2. 状态 5: 直接获取并执行连接命令(只执行一次,不需要轮询) + */ @bind - private loadOutput() { - const options = new URLSearchParams(decodeURIComponent(window.location.search)); - const params = new URLSearchParams({ - repo: options.get('repoid') as string, - user: options.get('userid') as string, - }); - - fetch( - 'http://' + options.get('domain') + ':'+ options.get('port') +'/' + - options.get('user') + - '/' + - options.get('repo') + - '/devcontainer/output?' + - params - ) - .then(response => response.json()) - .then(job => { - if (!job) { - clearInterval(this.intervalID); - this.intervalID = null as any; - return; + private async checkStatusAndStartPolling() { + try { + const response = await fetch(`${this.apiBaseUrl}/devcontainer/status?${this.apiParams}`); + const data = await response.json(); + + if (data.status === '-1') { + // 容器不存在,不启动轮询 + return; + } + + // 打开终端并连接 WebSocket + this.xterm.open(this.container); + if (this.isDockerType) { + this.xterm.changeContainerStatus(data.status); + } + this.xterm.connect(); + + const status = parseInt(data.status); + + if (this.isDockerType) { + // Docker 类型:根据状态选择轮询策略 + if (status >= 0 && status < 5) { + // 状态 0-4: 创建中,启动日志轮询(3秒一次) + // 日志轮询会在状态变为 5 时自动停止并获取连接命令 + this.startLogPolling(3000); + } else if (status === 5) { + // 状态 5: 创建完成,直接获取并执行连接命令 + this.loadCommandOnce(); } - if(this.currentDevcontainer.steps.length < job.currentDevcontainer.steps.length){ - for(let i = this.currentDevcontainer.steps.length; i < job.currentDevcontainer.steps.length; i++) { - this.xterm.writeData(job.currentDevcontainer.steps[i].summary); - this.xterm.writeData('\r\n'); - for(let j = 0; j < job.currentDevcontainer.steps[i].logs.length; j++) { - this.xterm.writeData(job.currentDevcontainer.steps[i].logs[j].message.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n')); - this.xterm.writeData('\r\n'); + } else { + // 非 Docker 类型:直接启动日志轮询 + this.startLogPolling(8000); + } + } catch (error) { + console.error('Error checking status:', error); + } + } + + /** + * 启动日志轮询(用于显示容器创建日志) + */ + @bind + private startLogPolling(interval: number = 1000) { + this.stopPolling(); + // 立即加载一次日志 + this.loadLogs(); + // 然后定期轮询 + this.pollingIntervalID = setInterval(this.loadLogs, interval); + } + + /** + * 获取并执行连接命令(只执行一次,不需要轮询) + */ + @bind + private loadCommandOnce() { + this.stopPolling(); + this.xterm.loadCommandOnce(); + } + + /** + * 停止所有轮询 + */ + @bind + private stopPolling() { + if (this.pollingIntervalID) { + clearInterval(this.pollingIntervalID); + this.pollingIntervalID = null; + } + } + + /** + * 规范化日志消息的换行符 + */ + private normalizeLogMessage(message: string): string { + return message.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); + } + + /** + * 输出日志行 + */ + private writeLogLine(message: string) { + this.xterm.writeData(this.normalizeLogMessage(message)); + this.xterm.writeData('\r\n'); + } + + /** + * 处理步骤日志更新 + * @returns + */ + private processStepLogs(newSteps: any[], oldSteps: any[]): boolean { + let detectedStartupComplete = false; + + for (let i = 0; i < newSteps.length; i++) { + const newStep = newSteps[i]; + const oldStep = oldSteps[i] as any; + + // 如果是新步骤,输出步骤标题 + if (!oldStep) { + // 如果不是第一个步骤,在前面添加换行 + if (i > 0) { + this.xterm.writeData('\r\n'); + } + this.xterm.writeData(`${newStep.summary}\r\n`); + // 输出该步骤的所有日志 + if (newStep.logs && newStep.logs.length > 0) { + for (let j = 0; j < newStep.logs.length; j++) { + this.writeLogLine(newStep.logs[j].message); + // 检测 DEVCONTAINER_STARTUP_COMPLETE + if (newStep.logs[j].message.includes('DEVCONTAINER_STARTUP_COMPLETE')) { + detectedStartupComplete = true; } } } + } else { + // 输出新增的日志行 + if (newStep.logs && oldStep.logs && newStep.logs.length > oldStep.logs.length) { + for (let j = oldStep.logs.length; j < newStep.logs.length; j++) { + this.writeLogLine(newStep.logs[j].message); + // 检测 DEVCONTAINER_STARTUP_COMPLETE + if (newStep.logs[j].message.includes('DEVCONTAINER_STARTUP_COMPLETE')) { + detectedStartupComplete = true; + } + } + } + } + } + + return detectedStartupComplete; + } + + /** + * 检查并处理状态变化 + * + * 日志轮询结束条件: + * 1. 状态从 0-4 变为 5:自动停止日志轮询,获取并执行连接命令 + * + * @param newState 新状态(从日志接口返回的 job.currentDevcontainer.state 获取) + * @returns 是否已获取连接命令 + */ + private checkAndHandleStatusChange(newState: string): boolean { + const prevStatus = parseInt(this.currentDevcontainer.state); + const currentStatus = parseInt(newState); + + // 状态变化处理:从创建中(0-4)变为完成(5)时,停止日志轮询并获取连接命令 + // 状态是从日志接口 (/devcontainer/logs) 返回的数据中获取的 + if (this.isDockerType && prevStatus >= 0 && prevStatus < 5 && currentStatus === 5) { + console.log('[Terminal] Container creation completed (status 0-4 -> 5), stopping log polling and loading command'); + this.stopPolling(); + this.loadCommandOnce(); + return true; // 已切换 + } + return false; // 未切换 + } + + /** + * 主动检查状态(用于检测到 DEVCONTAINER_STARTUP_COMPLETE 后) + * + * 当检测到 DEVCONTAINER_STARTUP_COMPLETE 但日志接口返回的状态还不是 5 时, + * 主动查询状态接口,如果状态为 5,则停止日志轮询并切换到命令轮询 + */ + @bind + private async checkStatusOnce() { + try { + const response = await fetch(`${this.apiBaseUrl}/devcontainer/status?${this.apiParams}`); + const data = await response.json(); + const status = parseInt(data.status); + + if (status === 5) { + console.log('[Terminal] Status check: container is ready (status 5), stopping log polling and loading command'); + this.stopPolling(); + this.loadCommandOnce(); + } else { + console.log('[Terminal] Status check: container not ready yet (status', status, '), log polling will continue'); + } + } catch (error) { + console.error('Error checking status:', error); + } + } + + /** + * 加载容器创建日志 + */ + @bind + private loadLogs() { + // 使用日志接口(从内存中获取实时日志) + // 日志接口返回的数据包含状态信息:job.currentDevcontainer.state + fetch(`${this.apiBaseUrl}/devcontainer/logs?${this.apiParams}`) + .then(response => response.json()) + .then(job => { + if (!job || !job.currentDevcontainer) { + // 如果日志接口没有数据,停止轮询 + this.stopPolling(); + return; + } + + const newSteps = job.currentDevcontainer.steps; + const oldSteps = this.currentDevcontainer.steps; + + // 处理步骤日志更新,检测是否出现 DEVCONTAINER_STARTUP_COMPLETE + const detectedStartupComplete = this.processStepLogs(newSteps, oldSteps); + + // 更新当前状态(从日志接口返回的状态) this.currentDevcontainer = job.currentDevcontainer; - if (this.currentDevcontainer.detail === '4' && this.intervalID) { - clearInterval(this.intervalID); - this.intervalID = null as any; + + // 检查并处理状态变化:从日志接口返回的状态中检测,如果状态变为 5,停止日志轮询并切换到命令轮询 + const statusChanged = this.checkAndHandleStatusChange(this.currentDevcontainer.state); + + // 如果检测到 DEVCONTAINER_STARTUP_COMPLETE 但状态还没变为 5,主动检查一次状态 + // 因为状态更新可能有延迟,需要主动轮询状态接口 + if (detectedStartupComplete && !statusChanged && this.isDockerType) { + console.log('[Terminal] Detected DEVCONTAINER_STARTUP_COMPLETE but status not 5 yet, checking status...'); + // 延迟一点再检查,给后端一点时间更新状态 + setTimeout(() => { + this.checkStatusOnce(); + }, 500); } }) .catch(error => { - console.error('Error:', error); + console.error('Error loading logs:', error); + // 如果日志接口失败,停止轮询 + this.stopPolling(); }); } } diff --git a/html/src/components/terminal/xterm/index.ts b/html/src/components/terminal/xterm/index.ts index b01a6c6..02ca3b8 100644 --- a/html/src/components/terminal/xterm/index.ts +++ b/html/src/components/terminal/xterm/index.ts @@ -112,7 +112,6 @@ export class Xterm { private containerStatus = ""; private attachCommandSent = false; private attachCommandSentAt?: number; - private beforeCommand?: string; constructor( private options: XtermOptions, private sendCb: () => void @@ -284,19 +283,11 @@ export class Xterm { register(addEventListener(socket, 'message', this.onSocketData as EventListener)); register(addEventListener(socket, 'close', this.onSocketClose as EventListener)); register(addEventListener(socket, 'error', () => (this.doReconnect = false))); - const options = new URLSearchParams(decodeURIComponent(window.location.search)); - if (options.get('type') === 'docker') { - if(this.containerStatus === '4' || this.containerStatus === '-1'){ - this.intervalID = setInterval(this.loadCommand, 1000); - }else{ - this.intervalID = setInterval(this.loadCommand, 8000); - } - } } @bind private onSocketOpen() { - console.log('[ttyd] websocket connection opened'); + console.log('[webTerminal] WebSocket opened, containerStatus:', this.containerStatus, 'connectStatus:', this.connectStatus, 'attachCommandSent:', this.attachCommandSent); const { textEncoder, terminal, overlayAddon } = this; const msg = JSON.stringify({ AuthToken: this.token, columns: terminal.cols, rows: terminal.rows }); @@ -306,6 +297,12 @@ export class Xterm { terminal.reset(); terminal.options.disableStdin = false; overlayAddon.showOverlay('Reconnected', 300); + // 重新连接后,如果状态是5且未连接,重置连接状态以便重新发送连接命令 + if (this.containerStatus === '5' && !this.connectStatus) { + console.log('[webTerminal] Reconnected, resetting attach command state'); + this.attachCommandSent = false; + this.attachCommandSentAt = undefined; + } } else { this.opened = true; } @@ -343,57 +340,89 @@ export class Xterm { } } - @bind - private loadCommand() { + /** + * 获取 URL 查询参数 + */ + private getUrlParams(): { options: URLSearchParams; params: URLSearchParams; baseUrl: string } { const options = new URLSearchParams(decodeURIComponent(window.location.search)); const params = new URLSearchParams({ repo: options.get('repoid') as string, user: options.get('userid') as string, }); + const baseUrl = `http://${options.get('domain')}:${options.get('port')}/${options.get('user')}/${options.get('repo')}`; + return { options, params, baseUrl }; + } - fetch( - 'http://' + options.get('domain') + ':'+ options.get('port') +'/' + - options.get('user') + - '/' + - options.get('repo') + - '/devcontainer/command?' + - params - ) - .then(response => response.json()) + /** + * 获取并执行连接容器的命令(带重试机制) + * + * 重试机制: + * - 最多重试 5 次 + * - 每次重试间隔递增(1s, 2s, 3s, 4s, 5s) + * - 如果成功获取命令,立即执行并停止重试 + */ + @bind + public loadCommandOnce() { + this.loadCommandWithRetry(0); + } + + /** + * 带重试的命令获取 + * @param retryCount 当前重试次数 + */ + @bind + private loadCommandWithRetry(retryCount: number = 0) { + const maxRetries = 5; + const { params, baseUrl } = this.getUrlParams(); + + fetch(`${baseUrl}/devcontainer/command?${params}`) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) .then(data => { + // 验证数据有效性 + if (!data || !data.command) { + throw new Error('Invalid command data received'); + } + if (this.workdir === ''){ this.workdir = data.workdir; } - if (data.status !== '4' && data.status !== '0') { - if(this.containerStatus !== data.status){ - this.sendData(data.command); - } - this.containerStatus = data.status; - } else { - if (this.containerStatus !== '4'){ - this.writeData("\x1b[31mCreation completed.\x1b[0m\r\n"); - } - this.containerStatus = data.status; - if (data.status === '4') { - const parts = data.command.split('\n'); - const shouldResend = this.attachCommandSent && this.attachCommandSentAt !== undefined && Date.now() - this.attachCommandSentAt > 5000; - if ((!this.attachCommandSent || shouldResend) && !this.connectStatus && parts[0]) { - this.sendData(parts[0]+"\n"); - this.attachCommandSent = true; - this.attachCommandSentAt = Date.now(); - } - this.postAttachCommand = parts; - } + + // 执行连接容器的命令(只执行一次) + const parts = data.command.split('\n'); + if (parts[0] && !this.connectStatus) { + console.log('[Xterm] Successfully loaded connection command, executing...'); + this.sendData(parts[0]+"\n"); + this.attachCommandSent = true; + this.attachCommandSentAt = Date.now(); } + this.postAttachCommand = parts; }) .catch(error => { - console.error('Error:', error); + console.error(`[Xterm] Error loading command (attempt ${retryCount + 1}/${maxRetries}):`, error); + + // 如果还有重试次数,继续重试 + if (retryCount < maxRetries - 1) { + const delay = (retryCount + 1) * 1000; // 递增延迟:1s, 2s, 3s, 4s, 5s + console.log(`[Xterm] Retrying command load in ${delay}ms...`); + setTimeout(() => { + this.loadCommandWithRetry(retryCount + 1); + }, delay); + } else { + console.error('[Xterm] Failed to load command after all retries'); + // 可以在这里显示错误提示给用户 + } }); } @bind public changeContainerStatus(v: string){ this.containerStatus = v; } + @bind private parseOptsFromUrlQuery(query: string): Preferences { const { terminal } = this; @@ -439,11 +468,7 @@ export class Xterm { const decodedData = textDecoder.decode(data); console.log('[ttyd] output:', decodedData); const compactOutput = decodedData.replace(/\s/g, ''); - const options = new URLSearchParams(decodeURIComponent(window.location.search)); - const params = new URLSearchParams({ - repo: options.get('repoid') as string, - user: options.get('userid') as string, - }); + const { options } = this.getUrlParams(); if (options.get('type') === 'docker') { // 保存host的标题 if (this.hostTitle === ''){ @@ -457,8 +482,8 @@ export class Xterm { this.attachCommandSentAt = undefined; this.postAttachCommandStatus = false; } - // this.connectStatus = true 连接完成 - //由于第二条docker命令中包含Successfully connected to the devcontainer,需要过滤否则会导致轮询终止,卡在状态2 + // 检测连接完成:监听 "Successfully connected to the devcontainer" 消息 + // 这条消息是由连接命令中的 echo "$WEB_TERMINAL_HELLO" 输出的 if (!this.connectStatus) { const sanitizedOutput = this.stripAnsi(decodedData).replace(/\r/g, '\n'); const combinedOutput = this.connectionMessageBuffer + sanitizedOutput; @@ -469,12 +494,12 @@ export class Xterm { this.connectStatus = true; this.connectionMessageBuffer = ''; this.attachCommandSentAt = undefined; - if (this.intervalID) { - clearInterval(this.intervalID); - } + console.log('[Xterm] Connection established, enabling terminal input'); + // 确保终端输入已启用 + this.terminal.options.disableStdin = false; } } - // 连接完成之前,不输出标题和docker命令 + // 连接完成之前,过滤掉 docker exec 命令的标题输出(ANSI 码和 docker-H 开头的输出) if ( !(this.connectStatus === false && (textDecoder.decode(data).includes('\x1b') || @@ -484,6 +509,7 @@ export class Xterm { } // 连接完成且出现容器的标题,且没有执行过postAttach命令 if (this.connectStatus && compactOutput.includes(this.workdir) && !this.postAttachCommandStatus){ + console.log('[Xterm] Detected workdir in output, executing postAttachCommand'); for (let i = 1; i < this.postAttachCommand.length; i++){ this.sendData(this.postAttachCommand[i]+'\n'); } @@ -495,12 +521,10 @@ export class Xterm { } break; case Command.SET_WINDOW_TITLE: - console.log('SET_WINDOW_TITLESET_WINDOW_TITLE'); this.title = textDecoder.decode(data); document.title = this.title; break; case Command.SET_PREFERENCES: - console.log('SET_PREFERENCESSET_PREFERENCESSET_PREFERENCES'); this.applyPreferences({ ...this.options.clientOptions, ...JSON.parse(textDecoder.decode(data)),