fix: terminal WebSocket connection — manage xterm containers in JS
- Remove Alpine x-for template for xterm containers (async rendering bug) - Add static #xterm-wrapper div, JS creates/removes container elements directly - addTab() uses document.createElement + appendChild for instant DOM - switchTab() toggles display:block/none, closeTab() removes from DOM - Add console.log for WebSocket open/close/error debugging - Terminal now connects on page load
This commit is contained in:
@@ -369,6 +369,9 @@
|
|||||||
.block {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
.contents {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,61 +31,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
Alpine.data('terminalManager', () => ({
|
Alpine.data('terminalManager', () => ({
|
||||||
tabs: [],
|
tabs: [],
|
||||||
activeTab: 0,
|
activeTab: 0,
|
||||||
terminals: {},
|
|
||||||
sockets: {},
|
|
||||||
command: '',
|
command: '',
|
||||||
|
_terminals: {},
|
||||||
|
_sockets: {},
|
||||||
|
_containers: {},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
this._wrapper = document.getElementById('xterm-wrapper');
|
||||||
this.addTab();
|
this.addTab();
|
||||||
},
|
},
|
||||||
|
|
||||||
_createTerminal(id, hostName) {
|
|
||||||
const term = new Terminal({
|
|
||||||
theme: termTheme,
|
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
|
||||||
fontSize: 13,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
cursorBlink: true,
|
|
||||||
cursorStyle: 'bar',
|
|
||||||
allowProposedApi: true,
|
|
||||||
scrollback: 5000,
|
|
||||||
});
|
|
||||||
this.terminals[id] = term;
|
|
||||||
return term;
|
|
||||||
},
|
|
||||||
|
|
||||||
_connectWebSocket(id, hostID, hostName) {
|
|
||||||
const ws = new WebSocket(
|
|
||||||
(location.protocol === 'https:' ? 'wss:' : 'ws:') +
|
|
||||||
'//' + location.host + '/terminal/ws/' + hostID
|
|
||||||
);
|
|
||||||
this.sockets[id] = ws;
|
|
||||||
|
|
||||||
const term = this.terminals[id];
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
term.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
term.write(ev.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
term.write('\r\n\x1b[33m[Connection closed]\x1b[0m\r\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
term.write('\r\n\x1b[31m[Connection error]\x1b[0m\r\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
term.onData((data) => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
_getHostForTab() {
|
_getHostForTab() {
|
||||||
let host;
|
let host;
|
||||||
if (selectedHostId) {
|
if (selectedHostId) {
|
||||||
@@ -104,30 +59,82 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const id = 'tab-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
const id = 'tab-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
||||||
|
|
||||||
this.tabs.push({ id, name: host.name, host: host.ip, hostID: host.id });
|
this.tabs.push({ id, name: host.name, host: host.ip, hostID: host.id });
|
||||||
this.activeTab = this.tabs.length - 1;
|
|
||||||
|
|
||||||
this._createTerminal(id, host.name);
|
// Create xterm container DOM element directly
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'xterm-' + id;
|
||||||
|
container.className = 'absolute inset-0';
|
||||||
|
container.style.display = 'none';
|
||||||
|
this._wrapper.appendChild(container);
|
||||||
|
this._containers[id] = container;
|
||||||
|
|
||||||
const tryOpen = (retries) => {
|
// Create xterm.js terminal
|
||||||
const container = document.getElementById('xterm-' + id);
|
const term = new Terminal({
|
||||||
if (container) {
|
theme: termTheme,
|
||||||
this.terminals[id].open(container);
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
this._connectWebSocket(id, host.id, host.name);
|
fontSize: 13,
|
||||||
} else if (retries > 0) {
|
lineHeight: 1.5,
|
||||||
requestAnimationFrame(() => tryOpen(retries - 1));
|
cursorBlink: true,
|
||||||
}
|
cursorStyle: 'bar',
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: 5000,
|
||||||
|
});
|
||||||
|
this._terminals[id] = term;
|
||||||
|
term.open(container);
|
||||||
|
|
||||||
|
// Connect WebSocket
|
||||||
|
const wsUrl =
|
||||||
|
(location.protocol === 'https:' ? 'wss:' : 'ws:') +
|
||||||
|
'//' + location.host + '/terminal/ws/' + host.id;
|
||||||
|
console.log('[terminal] connecting to', wsUrl);
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
this._sockets[id] = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('[terminal] ws open for', host.id);
|
||||||
|
term.focus();
|
||||||
};
|
};
|
||||||
this.$nextTick(() => tryOpen(10));
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
term.write(ev.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('[terminal] ws closed for', host.id);
|
||||||
|
term.write('\r\n\x1b[33m[Connection closed]\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
console.error('[terminal] ws error for', host.id, err);
|
||||||
|
term.write('\r\n\x1b[31m[Connection error]\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to new tab
|
||||||
|
this.activeTab = this.tabs.length - 1;
|
||||||
|
this._showActiveTerminal();
|
||||||
},
|
},
|
||||||
|
|
||||||
switchTab(i) {
|
switchTab(i) {
|
||||||
this.activeTab = i;
|
this.activeTab = i;
|
||||||
this.$nextTick(() => {
|
this._showActiveTerminal();
|
||||||
|
},
|
||||||
|
|
||||||
|
_showActiveTerminal() {
|
||||||
const cur = this.tabs[this.activeTab];
|
const cur = this.tabs[this.activeTab];
|
||||||
if (cur && this.terminals[cur.id]) {
|
if (!cur) return;
|
||||||
this.terminals[cur.id].focus();
|
Object.keys(this._containers).forEach((key) => {
|
||||||
}
|
this._containers[key].style.display = key === cur.id ? 'block' : 'none';
|
||||||
});
|
});
|
||||||
|
if (this._terminals[cur.id]) {
|
||||||
|
this._terminals[cur.id].focus();
|
||||||
|
this._terminals[cur.id].refresh(0, this._terminals[cur.id].rows);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
closeTab(i) {
|
closeTab(i) {
|
||||||
@@ -135,24 +142,24 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const tab = this.tabs[i];
|
const tab = this.tabs[i];
|
||||||
const id = tab.id;
|
const id = tab.id;
|
||||||
|
|
||||||
if (this.sockets[id]) {
|
if (this._sockets[id]) {
|
||||||
this.sockets[id].close();
|
this._sockets[id].close();
|
||||||
delete this.sockets[id];
|
delete this._sockets[id];
|
||||||
}
|
}
|
||||||
if (this.terminals[id]) {
|
if (this._terminals[id]) {
|
||||||
this.terminals[id].dispose();
|
this._terminals[id].dispose();
|
||||||
delete this.terminals[id];
|
delete this._terminals[id];
|
||||||
|
}
|
||||||
|
if (this._containers[id]) {
|
||||||
|
this._containers[id].remove();
|
||||||
|
delete this._containers[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tabs.splice(i, 1);
|
this.tabs.splice(i, 1);
|
||||||
if (this.activeTab >= this.tabs.length) {
|
if (this.activeTab >= this.tabs.length) {
|
||||||
this.activeTab = this.tabs.length - 1;
|
this.activeTab = this.tabs.length - 1;
|
||||||
}
|
}
|
||||||
|
this._showActiveTerminal();
|
||||||
const cur = this.tabs[this.activeTab];
|
|
||||||
if (cur && this.terminals[cur.id]) {
|
|
||||||
this.$nextTick(() => this.terminals[cur.id].focus());
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
submitCommand() {
|
submitCommand() {
|
||||||
@@ -160,7 +167,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (!cmd) return;
|
if (!cmd) return;
|
||||||
const cur = this.tabs[this.activeTab];
|
const cur = this.tabs[this.activeTab];
|
||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
const ws = this.sockets[cur.id];
|
const ws = this._sockets[cur.id];
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(cmd + '\r');
|
ws.send(cmd + '\r');
|
||||||
}
|
}
|
||||||
@@ -175,7 +182,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
clearBuffer() {
|
clearBuffer() {
|
||||||
const cur = this.tabs[this.activeTab];
|
const cur = this.tabs[this.activeTab];
|
||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
const term = this.terminals[cur.id];
|
const term = this._terminals[cur.id];
|
||||||
if (term) {
|
if (term) {
|
||||||
term.clear();
|
term.clear();
|
||||||
term.write('\x1b[2J\x1b[H');
|
term.write('\x1b[2J\x1b[H');
|
||||||
|
|||||||
@@ -49,16 +49,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- xterm.js containers — one per tab, shown/hidden by Alpine -->
|
<!-- xterm.js containers — managed by JS, not Alpine -->
|
||||||
<div class="flex-1 relative min-h-0">
|
<div id="xterm-wrapper" class="flex-1 relative min-h-0"></div>
|
||||||
<template x-for="(tab, idx) in tabs" :key="tab.id">
|
|
||||||
<div x-show="idx === activeTab"
|
|
||||||
x-cloak
|
|
||||||
class="absolute inset-0">
|
|
||||||
<div :id="'xterm-' + tab.id" class="w-full h-full"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Input prompt -->
|
<!-- Input prompt -->
|
||||||
<form @submit.prevent="submitCommand()"
|
<form @submit.prevent="submitCommand()"
|
||||||
|
|||||||
Reference in New Issue
Block a user