Merge pull request '重构开发容器创建过程' (#3) from fix/issue-32 into main
Some checks failed
docker / build (push) Failing after 2m44s

Reviewed-on: #3
This commit is contained in:
2026-01-05 02:19:46 +00:00
2 changed files with 314 additions and 132 deletions

View File

@@ -16,22 +16,15 @@ interface State {
export class Terminal extends Component<Props, State> {
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<Props, State> {
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<Props, State> {
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();
});
}
}

View File

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