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:
@@ -102,34 +102,34 @@ func mockTerminal(c *websocket.Conn, host string) {
|
|||||||
c.WriteMessage(websocket.TextMessage, []byte(welcome))
|
c.WriteMessage(websocket.TextMessage, []byte(welcome))
|
||||||
|
|
||||||
prompt := fmt.Sprintf("\x1b[1;34m%s:~$\x1b[0m ", host)
|
prompt := fmt.Sprintf("\x1b[1;34m%s:~$\x1b[0m ", host)
|
||||||
cwd := "~"
|
c.WriteMessage(websocket.TextMessage, []byte(prompt))
|
||||||
|
|
||||||
|
cwd := "~"
|
||||||
buf := ""
|
buf := ""
|
||||||
|
|
||||||
for {
|
for {
|
||||||
_, msg, err := c.ReadMessage()
|
_, msg, err := c.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
input := string(msg)
|
for _, ch := range string(msg) {
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case input == "\r":
|
case ch == '\r':
|
||||||
output := handleCommand(buf, host, &cwd)
|
output := handleCommand(buf, host, &cwd)
|
||||||
c.WriteMessage(websocket.TextMessage, []byte(output+prompt))
|
c.WriteMessage(websocket.TextMessage, []byte(output+prompt))
|
||||||
buf = ""
|
buf = ""
|
||||||
case input == "\x7f":
|
case ch == '\x7f':
|
||||||
if len(buf) > 0 {
|
if len(buf) > 0 {
|
||||||
buf = buf[:len(buf)-1]
|
buf = buf[:len(buf)-1]
|
||||||
c.WriteMessage(websocket.TextMessage, []byte("\b \b"))
|
c.WriteMessage(websocket.TextMessage, []byte("\b \b"))
|
||||||
}
|
}
|
||||||
case input == "\x03":
|
case ch == '\x03':
|
||||||
buf = ""
|
buf = ""
|
||||||
c.WriteMessage(websocket.TextMessage, []byte("^C\r\n"+prompt))
|
c.WriteMessage(websocket.TextMessage, []byte("^C\r\n"+prompt))
|
||||||
default:
|
case ch >= ' ' && ch <= '~':
|
||||||
if input >= " " && input <= "~" {
|
buf += string(ch)
|
||||||
buf += input
|
c.WriteMessage(websocket.TextMessage, []byte(string(ch)))
|
||||||
c.WriteMessage(websocket.TextMessage, []byte(input))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,41 +148,71 @@ func handleCommand(cmd, host string, cwd *string) string {
|
|||||||
return "\x1b[2J\x1b[H"
|
return "\x1b[2J\x1b[H"
|
||||||
case "exit", "logout":
|
case "exit", "logout":
|
||||||
return "logout\r\n\x1b[2J\x1b[H\x1b[1;31mConnection closed.\x1b[0m\r\n"
|
return "logout\r\n\x1b[2J\x1b[H\x1b[1;31mConnection closed.\x1b[0m\r\n"
|
||||||
case "pwd":
|
case "help":
|
||||||
return *cwd + "\r\n"
|
return "HostKeeper Mock SSH Interactive Command Parser:\r\n" +
|
||||||
case "whoami":
|
" help - Display this support manifest list\r\n" +
|
||||||
return "deploy\r\n"
|
" ls - List contents of the current working directory\r\n" +
|
||||||
case "hostname":
|
" docker ps - List simulated running Docker containers on cluster\r\n" +
|
||||||
return host + "\r\n"
|
" uname -a - Show operating system and machine kernel data\r\n" +
|
||||||
case "date":
|
" ping 8.8.8.8 - Probe network gateway performance\r\n" +
|
||||||
return time.Now().Format("Mon Jan 2 15:04:05 MST 2006") + "\r\n"
|
" cat server.js - Output snippet of remote index server configuration\r\n" +
|
||||||
case "uptime":
|
" keychain - Query keychain credentials loaded for target session\r\n" +
|
||||||
return fmt.Sprintf(" 13:42:17 up %d day, 1 user, load average: 0.08, 0.12, 0.10\r\n", 1+time.Now().Day()%30)
|
" clear - Wipe the terminal display buffer clean\r\n"
|
||||||
case "uname":
|
|
||||||
if len(parts) > 1 && parts[1] == "-a" {
|
|
||||||
return fmt.Sprintf("Linux %s 6.8.0-45-generic #47-Ubuntu SMP PREEMPT x86_64 x86_64 x86_64 GNU/Linux\r\n", host)
|
|
||||||
}
|
|
||||||
return "Linux\r\n"
|
|
||||||
case "ls":
|
case "ls":
|
||||||
if *cwd == "~" || *cwd == "/home/deploy" {
|
if *cwd == "~" || *cwd == "/home/deploy" {
|
||||||
return "Documents Downloads projects config.yml README.md .env\r\n"
|
return "drwxr-xr-x 3 root root 4096 Jul 6 12:00 controllers\r\n" +
|
||||||
|
"drwxr-xr-x 2 root root 4096 Jul 6 12:00 models\r\n" +
|
||||||
|
"drwxr-xr-x 2 root root 4096 Jul 6 12:00 routes\r\n" +
|
||||||
|
"-rw-r--r-- 1 root root 280 Jul 6 11:34 .env\r\n" +
|
||||||
|
"-rw-r--r-- 1 root root 1432 Jul 6 14:20 package.json\r\n" +
|
||||||
|
"-rwxr-xr-x 1 root root 8412 Jul 6 15:43 server.js\r\n"
|
||||||
}
|
}
|
||||||
return "total 0\r\n"
|
return "total 0\r\n"
|
||||||
case "cd":
|
case "cd":
|
||||||
if len(parts) > 1 { *cwd = parts[1] } else { *cwd = "~" }
|
if len(parts) > 1 { *cwd = parts[1] } else { *cwd = "~" }
|
||||||
return ""
|
return ""
|
||||||
|
case "docker ps":
|
||||||
|
return "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS\r\n" +
|
||||||
|
"f87a2d12e9b0 node:18-alpine \"docker-entrypoint.s…\" 2 hours ago Up 2 hours 0.0.0.0:3000->3000/tcp\r\n" +
|
||||||
|
"c23e84bf9211 postgres:15-alpine \"docker-entrypoint.s…\" 5 hours ago Up 5 hours 0.0.0.0:5432->5432/tcp\r\n" +
|
||||||
|
"78da12b84e0c redis:7-alpine \"docker-entrypoint.s…\" 10 hours ago Up 10 hours 0.0.0.0:6379->6379/tcp\r\n"
|
||||||
|
case "uname":
|
||||||
|
if len(parts) > 1 && parts[1] == "-a" {
|
||||||
|
return fmt.Sprintf("Linux %s 5.15.0-101-generic #111-Ubuntu SMP Wed Jul 6 21:27:00 UTC 2026 x86_64 GNU/Linux\r\n", host)
|
||||||
|
}
|
||||||
|
return "Linux\r\n"
|
||||||
|
case "ping":
|
||||||
|
return "64 bytes from 8.8.8.8: icmp_seq=1 ttl=116 time=12.4 ms\r\n" +
|
||||||
|
"64 bytes from 8.8.8.8: icmp_seq=2 ttl=116 time=14.1 ms\r\n" +
|
||||||
|
"64 bytes from 8.8.8.8: icmp_seq=3 ttl=116 time=11.8 ms\r\n" +
|
||||||
|
"--- 8.8.8.8 ping statistics ---\r\n" +
|
||||||
|
"3 packets transmitted, 3 received, 0% packet loss, rtt min/avg/max = 11.8/12.76/14.1 ms\r\n"
|
||||||
case "cat":
|
case "cat":
|
||||||
|
if len(parts) > 1 && parts[1] == "server.js" {
|
||||||
|
return "const express = require(\"express\");\r\n" +
|
||||||
|
"const app = express();\r\n" +
|
||||||
|
"const PORT = process.env.PORT || 3000;\r\n" +
|
||||||
|
"app.get(\"/api/health\", (req, res) => res.send({ status: \"healthy\" }));\r\n" +
|
||||||
|
"app.listen(PORT, () => console.log(\"Server active on cluster ingress\"));\r\n"
|
||||||
|
}
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
return fmt.Sprintf("\x1b[1;37m# Content of %s\x1b[0m\r\n(not implemented in mock shell)\r\n", parts[1])
|
return fmt.Sprintf("# Content of %s\r\n(not implemented in mock shell)\r\n", parts[1])
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
case "keychain":
|
||||||
|
return "Keychain Credential Mapping Selected:\r\n" +
|
||||||
|
" Active Key: id_ed25519_alex (ED25519 standard)\r\n" +
|
||||||
|
" Encryption: AES-256 GCM cryptokey payload\r\n" +
|
||||||
|
" Fingerprint: SHA256:7mP9K9+fVj5bW0vQ8zD1y2u3t4m5n6p7q8r9s0v1w2x\r\n"
|
||||||
|
case "whoami":
|
||||||
|
return "deploy\r\n"
|
||||||
|
case "hostname":
|
||||||
|
return host + "\r\n"
|
||||||
|
case "pwd":
|
||||||
|
return *cwd + "\r\n"
|
||||||
case "echo":
|
case "echo":
|
||||||
return strings.Join(parts[1:], " ") + "\r\n"
|
return strings.Join(parts[1:], " ") + "\r\n"
|
||||||
case "ssh":
|
|
||||||
return "\x1b[1;33mSSH forwarding not available in mock mode\x1b[0m\r\n"
|
|
||||||
case "help":
|
|
||||||
return "Available commands: clear, exit, pwd, whoami, hostname, date, uptime, uname, ls, cd, cat, echo, ssh, help\r\n"
|
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("\x1b[1;31m%s: command not found\x1b[0m\r\n", parts[0])
|
return fmt.Sprintf("hostkeeper: command not found: \"%s\". Type \"help\" to view custom commands list.\r\n", parts[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,11 @@
|
|||||||
background: #f1f4f9;
|
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 badges */
|
||||||
.strength-badge-secure {
|
.strength-badge-secure {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
--color-white: #fff;
|
--color-white: #fff;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
--container-sm: 24rem;
|
--container-sm: 24rem;
|
||||||
--container-md: 28rem;
|
|
||||||
--container-lg: 32rem;
|
--container-lg: 32rem;
|
||||||
--container-3xl: 48rem;
|
--container-3xl: 48rem;
|
||||||
--container-7xl: 80rem;
|
--container-7xl: 80rem;
|
||||||
@@ -301,6 +300,24 @@
|
|||||||
.col-span-full {
|
.col-span-full {
|
||||||
grid-column: 1 / -1;
|
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 {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
@@ -352,9 +369,6 @@
|
|||||||
.block {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.contents {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -875,12 +889,6 @@
|
|||||||
background-color: color-mix(in oklab, var(--color-error-container) 5%, transparent);
|
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 {
|
.bg-error\/80 {
|
||||||
background-color: color-mix(in srgb, #b3261e 80%, transparent);
|
background-color: color-mix(in srgb, #b3261e 80%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -959,12 +967,6 @@
|
|||||||
.bg-secondary {
|
.bg-secondary {
|
||||||
background-color: var(--color-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 {
|
.bg-secondary-container\/20 {
|
||||||
background-color: color-mix(in srgb, #6bff8f 20%, transparent);
|
background-color: color-mix(in srgb, #6bff8f 20%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -1288,9 +1290,6 @@
|
|||||||
.text-\[\#191c1e\] {
|
.text-\[\#191c1e\] {
|
||||||
color: #191c1e;
|
color: #191c1e;
|
||||||
}
|
}
|
||||||
.text-\[\#2244aa\] {
|
|
||||||
color: #2244aa;
|
|
||||||
}
|
|
||||||
.text-amber-500 {
|
.text-amber-500 {
|
||||||
color: var(--color-amber-500);
|
color: var(--color-amber-500);
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-102
@@ -4,129 +4,152 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const selEl = document.getElementById('selected-host');
|
const selEl = document.getElementById('selected-host');
|
||||||
const selectedHostId = selEl ? JSON.parse(selEl.textContent) : '';
|
const selectedHostId = selEl ? JSON.parse(selEl.textContent) : '';
|
||||||
|
|
||||||
function processCommand(cmd, tabName) {
|
const termTheme = {
|
||||||
const lines = [];
|
background: '#ffffff',
|
||||||
const clean = cmd.toLowerCase().trim();
|
foreground: '#191c1e',
|
||||||
if (!clean) return lines;
|
cursor: '#0050cb',
|
||||||
|
cursorAccent: '#ffffff',
|
||||||
if (clean === 'clear') return [];
|
selectionBackground: '#0050cb33',
|
||||||
|
black: '#191c1e',
|
||||||
const commands = {
|
red: '#b3261e',
|
||||||
help: [
|
green: '#006e2f',
|
||||||
{ text: 'HostKeeper Mock SSH Interactive Command Parser:', type: 'success' },
|
yellow: '#a86e0a',
|
||||||
{ text: ' help - Display this support manifest list', type: 'output' },
|
blue: '#0050cb',
|
||||||
{ text: ' ls - List contents of the current working directory', type: 'output' },
|
magenta: '#7e23cc',
|
||||||
{ text: ' docker ps - List simulated running Docker containers on cluster', type: 'output' },
|
cyan: '#006493',
|
||||||
{ text: ' uname -a - Show operating system and machine kernel data', type: 'output' },
|
white: '#191c1e',
|
||||||
{ text: ' ping 8.8.8.8 - Probe network gateway performance', type: 'output' },
|
brightBlack: '#727686',
|
||||||
{ text: ' cat server.js - Output snippet of remote index server configuration', type: 'output' },
|
brightRed: '#dc362e',
|
||||||
{ text: ' keychain - Query keychain credentials loaded for target session', type: 'output' },
|
brightGreen: '#008a42',
|
||||||
{ text: ' clear - Wipe the terminal display buffer clean', type: 'output' },
|
brightYellow: '#c58800',
|
||||||
],
|
brightBlue: '#2962d5',
|
||||||
ls: [
|
brightMagenta: '#9a40e8',
|
||||||
{ text: 'drwxr-xr-x 3 root root 4096 Jul 6 12:00 controllers', type: 'output' },
|
brightCyan: '#007ab5',
|
||||||
{ text: 'drwxr-xr-x 2 root root 4096 Jul 6 12:00 models', type: 'output' },
|
brightWhite: '#424656',
|
||||||
{ 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' }];
|
|
||||||
}
|
|
||||||
|
|
||||||
Alpine.data('terminalManager', () => ({
|
Alpine.data('terminalManager', () => ({
|
||||||
tabs: [],
|
tabs: [],
|
||||||
activeTab: 0,
|
activeTab: 0,
|
||||||
histories: {},
|
terminals: {},
|
||||||
|
sockets: {},
|
||||||
command: '',
|
command: '',
|
||||||
|
|
||||||
get history() {
|
|
||||||
const cur = this.tabs[this.activeTab];
|
|
||||||
return cur ? (this.histories[cur.id] || []) : [];
|
|
||||||
},
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.addTab();
|
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;
|
let host;
|
||||||
if (selectedHostId) {
|
if (selectedHostId) {
|
||||||
host = hostOptions.find(h => h.id === selectedHostId) || hostOptions[0];
|
host = hostOptions.find((h) => h.id === selectedHostId) || hostOptions[0];
|
||||||
}
|
}
|
||||||
if (!host) {
|
if (!host) {
|
||||||
host = hostOptions.length > 0
|
host = hostOptions.length > 0
|
||||||
? hostOptions[Math.floor(Math.random() * hostOptions.length)]
|
? hostOptions[Math.floor(Math.random() * hostOptions.length)]
|
||||||
: { id: 'h1', name: 'localhost', ip: '127.0.0.1' };
|
: { 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);
|
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.activeTab = this.tabs.length - 1;
|
||||||
this.histories[id] = [
|
|
||||||
{ text: 'Connecting to ' + host.name + ' (' + host.ip + ') via port 22...', type: 'output' },
|
this._createTerminal(id, host.name);
|
||||||
{ 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.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const out = document.getElementById('terminal-output');
|
const container = document.getElementById('xterm-' + id);
|
||||||
if (out) out.scrollTop = out.scrollHeight;
|
if (container) {
|
||||||
|
this.terminals[id].open(container);
|
||||||
|
this._connectWebSocket(id, host.id, host.name);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
switchTab(i) {
|
switchTab(i) {
|
||||||
this.activeTab = i;
|
this.activeTab = i;
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const out = document.getElementById('terminal-output');
|
const cur = this.tabs[this.activeTab];
|
||||||
if (out) out.scrollTop = out.scrollHeight;
|
if (cur && this.terminals[cur.id]) {
|
||||||
|
this.terminals[cur.id].focus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
closeTab(i) {
|
closeTab(i) {
|
||||||
if (this.tabs.length <= 1) return;
|
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);
|
this.tabs.splice(i, 1);
|
||||||
delete this.histories[id];
|
if (this.activeTab >= this.tabs.length) {
|
||||||
if (this.activeTab >= this.tabs.length) this.activeTab = this.tabs.length - 1;
|
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() {
|
submitCommand() {
|
||||||
@@ -134,16 +157,11 @@ 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 id = cur.id;
|
const ws = this.sockets[cur.id];
|
||||||
const lines = this.histories[id] || [];
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
lines.push({ text: '$ ' + cmd, type: 'input' });
|
ws.send(cmd + '\r');
|
||||||
const output = processCommand(cmd, cur.name);
|
}
|
||||||
this.histories[id] = lines.concat(output);
|
|
||||||
this.command = '';
|
this.command = '';
|
||||||
this.$nextTick(() => {
|
|
||||||
const out = document.getElementById('terminal-output');
|
|
||||||
if (out) out.scrollTop = out.scrollHeight;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
insertCommand(cmd) {
|
insertCommand(cmd) {
|
||||||
@@ -154,10 +172,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
clearBuffer() {
|
clearBuffer() {
|
||||||
const cur = this.tabs[this.activeTab];
|
const cur = this.tabs[this.activeTab];
|
||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
this.histories[cur.id] = [
|
const term = this.terminals[cur.id];
|
||||||
{ text: 'Session log display buffer cleared manually.', type: 'output' },
|
if (term) {
|
||||||
{ text: 'Type "help" to view interactive choices.', type: 'success' },
|
term.clear();
|
||||||
];
|
term.write('\x1b[2J\x1b[H');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<!-- Terminal shell frame -->
|
<!-- Terminal shell frame -->
|
||||||
<div class="flex-1 bg-white border border-outline-variant rounded-2xl shadow-sm flex flex-col overflow-hidden min-h-0">
|
<div class="flex-1 bg-white border border-outline-variant rounded-2xl shadow-sm flex flex-col overflow-hidden min-h-0">
|
||||||
<!-- Terminal Header -->
|
<!-- Terminal Header -->
|
||||||
<div class="px-4 py-2 bg-surface-container-low border-b border-outline-variant/30 flex justify-between items-center">
|
<div class="px-4 py-2 bg-surface-container-low border-b border-outline-variant/30 flex justify-between items-center shrink-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="w-3 h-3 rounded-full bg-error/80"></span>
|
<span class="w-3 h-3 rounded-full bg-error/80"></span>
|
||||||
<span class="w-3 h-3 rounded-full bg-primary-container/80"></span>
|
<span class="w-3 h-3 rounded-full bg-primary-container/80"></span>
|
||||||
@@ -49,18 +49,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Console display -->
|
<!-- xterm.js containers — one per tab, shown/hidden by Alpine -->
|
||||||
<div class="flex-1 overflow-y-auto min-h-0 font-mono text-sm leading-relaxed p-5 bg-white" id="terminal-output">
|
<div class="flex-1 relative min-h-0">
|
||||||
<template x-for="(line, idx) in history" :key="idx">
|
<template x-for="(tab, idx) in tabs" :key="tab.id">
|
||||||
<div :class="line.type === 'input' ? 'text-on-surface font-bold' : line.type === 'error' ? 'text-error bg-error-container/20 px-2.5 py-1 rounded-md' : line.type === 'success' ? 'text-secondary bg-secondary-container/10 px-2.5 py-1 rounded-md font-semibold' : 'text-[#2244aa]'"
|
<div x-show="idx === activeTab"
|
||||||
class="whitespace-pre-wrap" x-text="line.text"></div>
|
x-cloak
|
||||||
|
class="absolute inset-0">
|
||||||
|
<div :id="'xterm-' + tab.id" class="w-full h-full"></div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="h-4"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input prompt -->
|
<!-- Input prompt -->
|
||||||
<form @submit.prevent="submitCommand()"
|
<form @submit.prevent="submitCommand()"
|
||||||
class="p-3 border-t border-outline-variant/40 bg-surface-container-low flex items-center gap-2.5">
|
class="p-3 border-t border-outline-variant/40 bg-surface-container-low flex items-center gap-2.5 shrink-0">
|
||||||
<span class="font-mono text-xs font-bold text-primary pl-2 shrink-0 flex items-center gap-1">
|
<span class="font-mono text-xs font-bold text-primary pl-2 shrink-0 flex items-center gap-1">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 17.25L12 21m0 0l-3.75-3.75M12 21V3"/></svg>
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 17.25L12 21m0 0l-3.75-3.75M12 21V3"/></svg>
|
||||||
root@<span x-text="tabs.length ? tabs[activeTab].name : 'host'"></span>:~$
|
root@<span x-text="tabs.length ? tabs[activeTab].name : 'host'"></span>:~$
|
||||||
|
|||||||
Reference in New Issue
Block a user