diff --git a/app/backend/internal/handler/terminal.go b/app/backend/internal/handler/terminal.go index 2742faa..a2361f9 100644 --- a/app/backend/internal/handler/terminal.go +++ b/app/backend/internal/handler/terminal.go @@ -102,34 +102,34 @@ func mockTerminal(c *websocket.Conn, host string) { c.WriteMessage(websocket.TextMessage, []byte(welcome)) prompt := fmt.Sprintf("\x1b[1;34m%s:~$\x1b[0m ", host) - cwd := "~" + c.WriteMessage(websocket.TextMessage, []byte(prompt)) + cwd := "~" buf := "" + for { _, msg, err := c.ReadMessage() if err != nil { break } - input := string(msg) - - switch { - case input == "\r": - output := handleCommand(buf, host, &cwd) - c.WriteMessage(websocket.TextMessage, []byte(output+prompt)) - buf = "" - case input == "\x7f": - if len(buf) > 0 { - buf = buf[:len(buf)-1] - c.WriteMessage(websocket.TextMessage, []byte("\b \b")) - } - case input == "\x03": - buf = "" - c.WriteMessage(websocket.TextMessage, []byte("^C\r\n"+prompt)) - default: - if input >= " " && input <= "~" { - buf += input - c.WriteMessage(websocket.TextMessage, []byte(input)) + for _, ch := range string(msg) { + switch { + case ch == '\r': + output := handleCommand(buf, host, &cwd) + c.WriteMessage(websocket.TextMessage, []byte(output+prompt)) + buf = "" + case ch == '\x7f': + if len(buf) > 0 { + buf = buf[:len(buf)-1] + c.WriteMessage(websocket.TextMessage, []byte("\b \b")) + } + case ch == '\x03': + buf = "" + c.WriteMessage(websocket.TextMessage, []byte("^C\r\n"+prompt)) + case ch >= ' ' && ch <= '~': + buf += string(ch) + c.WriteMessage(websocket.TextMessage, []byte(string(ch))) } } } @@ -148,41 +148,71 @@ func handleCommand(cmd, host string, cwd *string) string { return "\x1b[2J\x1b[H" case "exit", "logout": return "logout\r\n\x1b[2J\x1b[H\x1b[1;31mConnection closed.\x1b[0m\r\n" - case "pwd": - return *cwd + "\r\n" - case "whoami": - return "deploy\r\n" - case "hostname": - return host + "\r\n" - case "date": - return time.Now().Format("Mon Jan 2 15:04:05 MST 2006") + "\r\n" - case "uptime": - 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) - 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 "help": + return "HostKeeper Mock SSH Interactive Command Parser:\r\n" + + " help - Display this support manifest list\r\n" + + " ls - List contents of the current working directory\r\n" + + " docker ps - List simulated running Docker containers on cluster\r\n" + + " uname -a - Show operating system and machine kernel data\r\n" + + " ping 8.8.8.8 - Probe network gateway performance\r\n" + + " cat server.js - Output snippet of remote index server configuration\r\n" + + " keychain - Query keychain credentials loaded for target session\r\n" + + " clear - Wipe the terminal display buffer clean\r\n" case "ls": 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" case "cd": if len(parts) > 1 { *cwd = parts[1] } else { *cwd = "~" } 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": + 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 { - 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 "" + 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": 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: - 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]) } } diff --git a/app/backend/static/css/custom.css b/app/backend/static/css/custom.css index 73d218a..39e06db 100644 --- a/app/backend/static/css/custom.css +++ b/app/backend/static/css/custom.css @@ -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; diff --git a/app/backend/static/css/output.css b/app/backend/static/css/output.css index a7b82ef..551c3af 100644 --- a/app/backend/static/css/output.css +++ b/app/backend/static/css/output.css @@ -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); } diff --git a/app/backend/static/js/terminal.js b/app/backend/static/js/terminal.js index 9e2b5f2..59de30a 100644 --- a/app/backend/static/js/terminal.js +++ b/app/backend/static/js/terminal.js @@ -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'); + } }, })); }); diff --git a/app/backend/views/terminal_content.html b/app/backend/views/terminal_content.html index c488f78..c9bcb13 100644 --- a/app/backend/views/terminal_content.html +++ b/app/backend/views/terminal_content.html @@ -33,7 +33,7 @@