feat: upgrade terminal to xterm.js + WebSocket

- Rewrite terminal.js: xterm.js Terminal per tab, WebSocket per connection
- Light theme terminal (Lumina: white bg, blue cursor, proper ANSI colors)
- Alpine.js keeps tab management, xterm.js handles rendering
- Each tab gets its own WebSocket to /terminal/ws/:hostID
- Mock handler iterates chars (fixes multi-char xterm.js messages)
- Sync mock commands with React template (help, ls, docker ps, etc.)
- Add xterm.css custom scrollbar styles
- terminal_content.html: xterm containers with Alpine show/hide per tab
This commit is contained in:
swanadiva
2026-07-09 18:32:33 +07:00
parent 7accb86203
commit ef4e44984b
5 changed files with 228 additions and 173 deletions
+5
View File
@@ -176,6 +176,11 @@
background: #f1f4f9;
}
/* xterm.js container */
.xterm { padding: 8px 0; }
.xterm-viewport::-webkit-scrollbar { width: 6px; }
.xterm-viewport::-webkit-scrollbar-thumb { background: #c2c6d8; border-radius: 3px; }
/* Strength badges */
.strength-badge-secure {
display: inline-flex;
+18 -19
View File
@@ -16,7 +16,6 @@
--color-white: #fff;
--spacing: 0.25rem;
--container-sm: 24rem;
--container-md: 28rem;
--container-lg: 32rem;
--container-3xl: 48rem;
--container-7xl: 80rem;
@@ -301,6 +300,24 @@
.col-span-full {
grid-column: 1 / -1;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.mx-auto {
margin-inline: auto;
}
@@ -352,9 +369,6 @@
.block {
display: block;
}
.contents {
display: contents;
}
.flex {
display: flex;
}
@@ -875,12 +889,6 @@
background-color: color-mix(in oklab, var(--color-error-container) 5%, transparent);
}
}
.bg-error-container\/20 {
background-color: color-mix(in srgb, #f9dedc 20%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-error-container) 20%, transparent);
}
}
.bg-error\/80 {
background-color: color-mix(in srgb, #b3261e 80%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -959,12 +967,6 @@
.bg-secondary {
background-color: var(--color-secondary);
}
.bg-secondary-container\/10 {
background-color: color-mix(in srgb, #6bff8f 10%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-secondary-container) 10%, transparent);
}
}
.bg-secondary-container\/20 {
background-color: color-mix(in srgb, #6bff8f 20%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -1288,9 +1290,6 @@
.text-\[\#191c1e\] {
color: #191c1e;
}
.text-\[\#2244aa\] {
color: #2244aa;
}
.text-amber-500 {
color: var(--color-amber-500);
}
+122 -103
View File
@@ -4,129 +4,152 @@ document.addEventListener('alpine:init', () => {
const selEl = document.getElementById('selected-host');
const selectedHostId = selEl ? JSON.parse(selEl.textContent) : '';
function processCommand(cmd, tabName) {
const lines = [];
const clean = cmd.toLowerCase().trim();
if (!clean) return lines;
if (clean === 'clear') return [];
const commands = {
help: [
{ text: 'HostKeeper Mock SSH Interactive Command Parser:', type: 'success' },
{ text: ' help - Display this support manifest list', type: 'output' },
{ text: ' ls - List contents of the current working directory', type: 'output' },
{ text: ' docker ps - List simulated running Docker containers on cluster', type: 'output' },
{ text: ' uname -a - Show operating system and machine kernel data', type: 'output' },
{ text: ' ping 8.8.8.8 - Probe network gateway performance', type: 'output' },
{ text: ' cat server.js - Output snippet of remote index server configuration', type: 'output' },
{ text: ' keychain - Query keychain credentials loaded for target session', type: 'output' },
{ text: ' clear - Wipe the terminal display buffer clean', type: 'output' },
],
ls: [
{ text: 'drwxr-xr-x 3 root root 4096 Jul 6 12:00 controllers', type: 'output' },
{ text: 'drwxr-xr-x 2 root root 4096 Jul 6 12:00 models', type: 'output' },
{ text: 'drwxr-xr-x 2 root root 4096 Jul 6 12:00 routes', type: 'output' },
{ text: '-rw-r--r-- 1 root root 280 Jul 6 11:34 .env', type: 'error' },
{ text: '-rw-r--r-- 1 root root 1432 Jul 6 14:20 package.json', type: 'output' },
{ text: '-rwxr-xr-x 1 root root 8412 Jul 6 15:43 server.js', type: 'success' },
],
'docker ps': [
{ text: 'CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS', type: 'success' },
{ text: 'f87a2d12e9b0 node:18-alpine "docker-entrypoint.s…" 2 hours ago Up 2 hours 0.0.0.0:3000->3000/tcp', type: 'output' },
{ text: 'c23e84bf9211 postgres:15-alpine "docker-entrypoint.s…" 5 hours ago Up 5 hours 0.0.0.0:5432->5432/tcp', type: 'output' },
{ text: '78da12b84e0c redis:7-alpine "docker-entrypoint.s…" 10 hours ago Up 10 hours 0.0.0.0:6379->6379/tcp', type: 'output' },
],
'uname -a': [
{ text: 'Linux ' + tabName + ' 5.15.0-101-generic #111-Ubuntu SMP Wed Jul 6 21:27:00 UTC 2026 x86_64 GNU/Linux', type: 'output' },
],
'cat server.js': [
{ text: 'const express = require("express");', type: 'output' },
{ text: 'const app = express();', type: 'output' },
{ text: 'const PORT = process.env.PORT || 3000;', type: 'output' },
{ text: 'app.get("/api/health", (req, res) => res.send({ status: "healthy" }));', type: 'output' },
{ text: 'app.listen(PORT, () => console.log("Server active on cluster ingress"));', type: 'success' },
],
keychain: [
{ text: 'Keychain Credential Mapping Selected:', type: 'success' },
{ text: ' Active Key: id_ed25519_alex (ED25519 standard)', type: 'output' },
{ text: ' Encryption: AES-256 GCM cryptokey payload', type: 'output' },
{ text: ' Fingerprint: SHA256:7mP9K9+fVj5bW0vQ8zD1y2u3t4m5n6p7q8r9s0v1w2x', type: 'output' },
],
};
if (commands[clean]) {
return commands[clean];
}
if (clean.startsWith('ping')) {
return [
{ text: '64 bytes from 8.8.8.8: icmp_seq=1 ttl=116 time=12.4 ms', type: 'output' },
{ text: '64 bytes from 8.8.8.8: icmp_seq=2 ttl=116 time=14.1 ms', type: 'output' },
{ text: '64 bytes from 8.8.8.8: icmp_seq=3 ttl=116 time=11.8 ms', type: 'output' },
{ text: '--- 8.8.8.8 ping statistics ---', type: 'success' },
{ text: '3 packets transmitted, 3 received, 0% packet loss, rtt min/avg/max = 11.8/12.76/14.1 ms', type: 'success' },
];
}
return [{ text: `hostkeeper: command not found: "${cmd}". Type "help" to view custom commands list.`, type: 'error' }];
}
const termTheme = {
background: '#ffffff',
foreground: '#191c1e',
cursor: '#0050cb',
cursorAccent: '#ffffff',
selectionBackground: '#0050cb33',
black: '#191c1e',
red: '#b3261e',
green: '#006e2f',
yellow: '#a86e0a',
blue: '#0050cb',
magenta: '#7e23cc',
cyan: '#006493',
white: '#191c1e',
brightBlack: '#727686',
brightRed: '#dc362e',
brightGreen: '#008a42',
brightYellow: '#c58800',
brightBlue: '#2962d5',
brightMagenta: '#9a40e8',
brightCyan: '#007ab5',
brightWhite: '#424656',
};
Alpine.data('terminalManager', () => ({
tabs: [],
activeTab: 0,
histories: {},
terminals: {},
sockets: {},
command: '',
get history() {
const cur = this.tabs[this.activeTab];
return cur ? (this.histories[cur.id] || []) : [];
},
init() {
this.addTab();
},
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() {
let host;
if (selectedHostId) {
host = hostOptions.find(h => h.id === selectedHostId) || hostOptions[0];
host = hostOptions.find((h) => h.id === selectedHostId) || hostOptions[0];
}
if (!host) {
host = hostOptions.length > 0
? hostOptions[Math.floor(Math.random() * hostOptions.length)]
: { id: 'h1', name: 'localhost', ip: '127.0.0.1' };
}
return host;
},
addTab() {
const host = this._getHostForTab();
const id = 'tab-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
this.tabs.push({ id, name: host.name, host: host.ip, connected: true });
this.tabs.push({ id, name: host.name, host: host.ip, hostID: host.id });
this.activeTab = this.tabs.length - 1;
this.histories[id] = [
{ text: 'Connecting to ' + host.name + ' (' + host.ip + ') via port 22...', type: 'output' },
{ text: 'Using identity keychain file: id_ed25519_alex (strength: SECURE)', type: 'output' },
{ text: 'Welcome to Ubuntu 22.04 LTS (GNU/Linux 5.15.0-101-generic x86_64)', type: 'success' },
{ text: 'System load: 0.12 | Processes: 104 | Memory: 32% used', type: 'output' },
{ text: 'Type "help" to view custom interactive HostKeeper mock commands.', type: 'success' },
];
this._createTerminal(id, host.name);
this.$nextTick(() => {
const out = document.getElementById('terminal-output');
if (out) out.scrollTop = out.scrollHeight;
const container = document.getElementById('xterm-' + id);
if (container) {
this.terminals[id].open(container);
this._connectWebSocket(id, host.id, host.name);
}
});
},
switchTab(i) {
this.activeTab = i;
this.$nextTick(() => {
const out = document.getElementById('terminal-output');
if (out) out.scrollTop = out.scrollHeight;
const cur = this.tabs[this.activeTab];
if (cur && this.terminals[cur.id]) {
this.terminals[cur.id].focus();
}
});
},
closeTab(i) {
if (this.tabs.length <= 1) return;
const id = this.tabs[i].id;
const tab = this.tabs[i];
const id = tab.id;
if (this.sockets[id]) {
this.sockets[id].close();
delete this.sockets[id];
}
if (this.terminals[id]) {
this.terminals[id].dispose();
delete this.terminals[id];
}
this.tabs.splice(i, 1);
delete this.histories[id];
if (this.activeTab >= this.tabs.length) this.activeTab = this.tabs.length - 1;
if (this.activeTab >= this.tabs.length) {
this.activeTab = this.tabs.length - 1;
}
const cur = this.tabs[this.activeTab];
if (cur && this.terminals[cur.id]) {
this.$nextTick(() => this.terminals[cur.id].focus());
}
},
submitCommand() {
@@ -134,16 +157,11 @@ document.addEventListener('alpine:init', () => {
if (!cmd) return;
const cur = this.tabs[this.activeTab];
if (!cur) return;
const id = cur.id;
const lines = this.histories[id] || [];
lines.push({ text: '$ ' + cmd, type: 'input' });
const output = processCommand(cmd, cur.name);
this.histories[id] = lines.concat(output);
const ws = this.sockets[cur.id];
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(cmd + '\r');
}
this.command = '';
this.$nextTick(() => {
const out = document.getElementById('terminal-output');
if (out) out.scrollTop = out.scrollHeight;
});
},
insertCommand(cmd) {
@@ -154,10 +172,11 @@ document.addEventListener('alpine:init', () => {
clearBuffer() {
const cur = this.tabs[this.activeTab];
if (!cur) return;
this.histories[cur.id] = [
{ text: 'Session log display buffer cleared manually.', type: 'output' },
{ text: 'Type "help" to view interactive choices.', type: 'success' },
];
const term = this.terminals[cur.id];
if (term) {
term.clear();
term.write('\x1b[2J\x1b[H');
}
},
}));
});