diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index eb55c33..5447914 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -8,6 +8,8 @@ import config HOSTS = { "nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH}, "vps": {"host": config.VPS_SSH_HOST, "user": config.VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH}, + "claude-dev": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH, + "command": "/volume1/@appstore/ContainerManager/usr/bin/docker exec -it claude-dev bash"}, } @@ -35,7 +37,7 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str = try: process = await conn.create_process( - term_type="xterm-256color", term_size=(80, 24), + h.get("command"), term_type="xterm-256color", term_size=(80, 24), encoding=None, ) diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte index cbab066..9958db5 100644 --- a/dashboard/frontend/src/routes/Terminal.svelte +++ b/dashboard/frontend/src/routes/Terminal.svelte @@ -4,37 +4,55 @@ const hosts = [ { id: "nas", label: "NAS" }, { id: "vps", label: "VPS" }, + { id: "claude-dev", label: "Claude Dev" }, ]; - let mobileHost = $state("nas"); - let terminals = $state({}); - let cleanups = []; + let tabs = $state([]); + let activeTab = $state(null); + let showMenu = $state(false); + let tabCounter = 0; + const tabData = new Map(); - function initTerminal(el, hostId) { + function addTab(hostId) { + const id = ++tabCounter; + const label = hosts.find((h) => h.id === hostId).label; + tabs = [...tabs, { id, hostId, label, connected: false, error: "" }]; + activeTab = id; + showMenu = false; + } + + function closeTab(id) { + const d = tabData.get(id); + if (d) { + d.ro?.disconnect(); + d.ws?.close(); + d.term?.dispose(); + tabData.delete(id); + } + tabs = tabs.filter((t) => t.id !== id); + if (activeTab === id) activeTab = tabs.length ? tabs[tabs.length - 1].id : null; + } + + function updateTab(tabId, props) { + tabs = tabs.map((t) => (t.id === tabId ? { ...t, ...props } : t)); + } + + function initTerminal(el, tab) { + const tabId = tab.id; (async () => { const { Terminal } = await import("@xterm/xterm"); const { FitAddon } = await import("@xterm/addon-fit"); await import("@xterm/xterm/css/xterm.css"); - const term = new Terminal({ - cursorBlink: true, - fontSize: 13, + cursorBlink: true, fontSize: 13, fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace", lineHeight: 1.4, theme: { - background: "#0f172a", - foreground: "#e2e8f0", - cursor: "#6366f1", - cursorAccent: "#0f172a", - selectionBackground: "#334155", - black: "#0f172a", - red: "#f43f5e", - green: "#10b981", - yellow: "#f59e0b", - blue: "#3b82f6", - magenta: "#a855f7", - cyan: "#06b6d4", - white: "#e2e8f0", + background: "#0f172a", foreground: "#e2e8f0", cursor: "#6366f1", + cursorAccent: "#0f172a", selectionBackground: "#334155", + black: "#0f172a", red: "#f43f5e", green: "#10b981", + yellow: "#f59e0b", blue: "#3b82f6", magenta: "#a855f7", + cyan: "#06b6d4", white: "#e2e8f0", }, }); const fit = new FitAddon(); @@ -45,10 +63,9 @@ const proto = location.protocol === "https:" ? "wss:" : "ws:"; const token = localStorage.getItem("token") || ""; const ws = new WebSocket( - `${proto}//${location.host}/ws/terminal?token=${encodeURIComponent(token)}&host=${hostId}` + `${proto}//${location.host}/ws/terminal?token=${encodeURIComponent(token)}&host=${tab.hostId}` ); ws.binaryType = "arraybuffer"; - function sendSize() { if (ws.readyState === 1) { const buf = new Uint8Array(5); @@ -59,79 +76,80 @@ } } - ws.onopen = () => { - terminals[hostId] = { connected: true, error: "" }; - sendSize(); - }; + ws.onopen = () => { updateTab(tabId, { connected: true }); sendSize(); }; ws.onmessage = (e) => term.write(new Uint8Array(e.data)); ws.onclose = () => { - terminals[hostId] = { ...terminals[hostId], connected: false }; + updateTab(tabId, { connected: false }); term.write("\r\n\x1b[90m[Connection closed]\x1b[0m"); }; - ws.onerror = () => { - terminals[hostId] = { connected: false, error: "WebSocket connection failed" }; - }; + ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" }); term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data))); - const ro = new ResizeObserver(() => { - fit.fit(); - sendSize(); - }); + const ro = new ResizeObserver(() => { fit.fit(); sendSize(); }); ro.observe(el); - - cleanups.push(() => { - ro.disconnect(); - ws.close(); - term.dispose(); - }); + tabData.set(tabId, { ws, term, ro, el }); })(); } - onDestroy(() => cleanups.forEach((fn) => fn())); - - function statusFor(hostId) { - const t = terminals[hostId]; - if (!t) return { connected: false, error: "" }; - return t; - } + onDestroy(() => tabData.forEach((d) => { d.ro?.disconnect(); d.ws?.close(); d.term?.dispose(); })); +