feat: use xterm.js terminal for LXC containers in VM list context menu

- Context menu now shows Terminal (xterm.js) for lxc type and VNC Console for qemu
- Added openTerminalConsole() / connectTerminalConsole() / closeTerminalModal() functions
- Added terminalModal HTML alongside existing vncModal
- Backend endpoints /api/{server_id}/container/{vmid}/terminal and
  /proxmox/ws/terminal/{server_id}/{node}/{vmid} already existed and are ready
This commit is contained in:
markmorado
2026-03-27 21:22:27 +01:00
parent 32acd74b38
commit 29f1255705

View File

@@ -1718,10 +1718,17 @@ function showVMActions(event, serverId, vmid, type, node, vmName, status) {
</div>
`}
<div class="vm-context-menu-divider"></div>
${type === 'lxc' ? `
<div class="vm-context-menu-item" onclick="openTerminalConsole(${serverId}, ${vmid}, '${node}', '${vmName}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>
<span>{{ t('terminal_console') or 'Terminal' }}</span>
</div>
` : `
<div class="vm-context-menu-item" onclick="openVNCConsole(${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
<span>VNC {{ t('console') }}</span>
</div>
`}
<div class="vm-context-menu-item" onclick="openVMSettings(${serverId}, ${vmid}, '${type}', '${node}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>{{ t('settings') }}</span>
@@ -2094,6 +2101,175 @@ function closeVNCModal() {
}
}
// === TERMINAL (xterm.js) FUNCTIONS ===
let terminalInstance = null;
let terminalWebSocket = null;
let fitAddon = null;
async function openTerminalConsole(serverId, vmid, node, vmName) {
closeVMContextMenu();
const modal = document.getElementById('terminalModal');
document.getElementById('terminalTitle').innerHTML = `<i class="fa-solid fa-terminal"></i> Terminal: ${vmName || 'Container'} (${vmid})`;
document.getElementById('terminalStatus').textContent = t('connecting') + '...';
document.getElementById('terminalContent').innerHTML = '';
modal.style.display = 'flex';
setTimeout(() => {
connectTerminalConsole(serverId, vmid, node);
}, 100);
}
async function connectTerminalConsole(serverId, vmid, node) {
const terminalContent = document.getElementById('terminalContent');
const statusEl = document.getElementById('terminalStatus');
try {
terminalInstance = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", monospace',
theme: {
background: '#1a1a1a',
foreground: '#e0e0e0',
cursor: '#00ff00',
cursorAccent: '#1a1a1a',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#000000',
red: '#ff5555',
green: '#50fa7b',
yellow: '#f1fa8c',
blue: '#6272a4',
magenta: '#ff79c6',
cyan: '#8be9fd',
white: '#f8f8f2',
brightBlack: '#6272a4',
brightRed: '#ff6e6e',
brightGreen: '#69ff94',
brightYellow: '#ffffa5',
brightBlue: '#d6acff',
brightMagenta: '#ff92df',
brightCyan: '#a4ffff',
brightWhite: '#ffffff'
},
allowTransparency: false,
scrollback: 10000,
tabStopWidth: 4
});
fitAddon = new FitAddon.FitAddon();
terminalInstance.loadAddon(fitAddon);
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
terminalInstance.loadAddon(webLinksAddon);
terminalInstance.open(terminalContent);
fitAddon.fit();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host;
const wsUrl = `${protocol}//${wsHost}/proxmox/ws/terminal/${serverId}/${node}/${vmid}`;
terminalWebSocket = new WebSocket(wsUrl);
terminalWebSocket.binaryType = 'arraybuffer';
const textEncoder = new TextEncoder();
terminalWebSocket.onopen = () => {
statusEl.textContent = t('connected') || 'Connected';
statusEl.style.color = 'var(--success)';
terminalInstance.focus();
const sendResize = () => {
const dims = fitAddon.proposeDimensions();
const cols = dims ? dims.cols : 80;
const rows = dims ? dims.rows : 24;
terminalWebSocket.send(`1:${cols}:${rows}:`);
};
sendResize();
setTimeout(sendResize, 200);
};
terminalWebSocket.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
terminalInstance.write(new Uint8Array(event.data));
} else {
terminalInstance.write(event.data);
}
};
terminalWebSocket.onclose = (event) => {
statusEl.textContent = t('disconnected') || 'Disconnected';
statusEl.style.color = 'var(--danger)';
if (!event.wasClean) {
terminalInstance.write('\r\n\x1b[31m*** Connection closed ***\x1b[0m\r\n');
}
};
terminalWebSocket.onerror = (error) => {
console.error('Terminal WebSocket error:', error);
statusEl.textContent = t('error') || 'Error';
statusEl.style.color = 'var(--danger)';
};
terminalInstance.onData(data => {
if (terminalWebSocket && terminalWebSocket.readyState === WebSocket.OPEN) {
const byteLength = textEncoder.encode(data).length;
terminalWebSocket.send(`0:${byteLength}:${data}`);
}
});
const handleResize = () => {
if (fitAddon && terminalInstance && terminalWebSocket && terminalWebSocket.readyState === WebSocket.OPEN) {
fitAddon.fit();
const dims = fitAddon.proposeDimensions();
if (dims) {
terminalWebSocket.send(`1:${dims.cols}:${dims.rows}:`);
}
}
};
window.addEventListener('resize', handleResize);
window.terminalPingInterval = setInterval(() => {
if (terminalWebSocket && terminalWebSocket.readyState === WebSocket.OPEN) {
terminalWebSocket.send('2');
}
}, 120000);
} catch (error) {
console.error('Terminal error:', error);
statusEl.textContent = t('error') || 'Error';
statusEl.style.color = 'var(--danger)';
terminalContent.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="background: var(--bg-tertiary); padding: 2rem; border-radius: 8px;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-triangle-exclamation"></i></div>
<h3 style="color: var(--text-primary); margin-bottom: 1rem;">${t('terminal_connection_error') || 'Terminal Connection Error'}</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">${error.message}</p>
<button onclick="closeTerminalModal()" class="btn">${t('close') || 'Close'}</button>
</div>
</div>
`;
}
}
function closeTerminalModal() {
if (window.terminalPingInterval) {
clearInterval(window.terminalPingInterval);
window.terminalPingInterval = null;
}
if (terminalWebSocket) {
terminalWebSocket.close();
terminalWebSocket = null;
}
if (terminalInstance) {
terminalInstance.dispose();
terminalInstance = null;
}
fitAddon = null;
document.getElementById('terminalModal').style.display = 'none';
}
function openVMSettings(serverId, vmid, type, node) {
closeVMContextMenu();
// Store context for modal
@@ -3324,6 +3500,20 @@ window.addEventListener('DOMContentLoaded', () => {
</div>
</div>
<!-- Terminal Modal -->
<div id="terminalModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.95); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 1200px; width: 95%; max-height: 90vh; display: flex; flex-direction: column;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-color);">
<h3 id="terminalTitle" style="margin: 0;"><i class="fa-solid fa-terminal"></i> {{ t('terminal_console') or 'Terminal' }}</h3>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<span id="terminalStatus" style="font-size: 0.75rem; color: var(--text-muted);"></span>
<button onclick="closeTerminalModal()" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; padding: 0.5rem;">&times;</button>
</div>
</div>
<div id="terminalContent" style="flex: 1; min-height: 500px; background: #000; border-radius: 4px; overflow: hidden;"></div>
</div>
</div>
<!-- Delete Confirm Modal -->
<div id="deleteConfirmModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 450px; width: 95%;">