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:
swanadiva
2026-07-09 19:28:15 +07:00
parent 71864336f6
commit d5406eab6c
3 changed files with 90 additions and 88 deletions
+3
View File
@@ -369,6 +369,9 @@
.block { .block {
display: block; display: block;
} }
.contents {
display: contents;
}
.flex { .flex {
display: flex; display: flex;
} }
+84 -77
View File
@@ -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');
+2 -10
View File
@@ -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()"