From c9f06fbbbb95d1d3d524b91c7c7ac1dee332802a Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Fri, 6 Mar 2026 16:11:43 +0800 Subject: [PATCH] fix: keep terminal sessions alive Add WebSocket and SSH keepalives so idle dashboard terminal sessions stay connected across proxies and long-running idle periods. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/backend/routers/terminal.py | 4 ++++ dashboard/frontend/src/routes/Terminal.svelte | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index dd4fc8e..784b2ae 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -72,6 +72,8 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): conn = await asyncssh.connect( h["host"], username=h["user"], client_keys=[h["key"]], known_hosts=_known_hosts, + keepalive_interval=30, + keepalive_count_max=3, ) except Exception: _active_sessions.discard(session_id) @@ -108,6 +110,8 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): else: process.stdin.write(data) elif "text" in msg and msg["text"]: + if msg["text"] == "__ping__": + continue process.stdin.write(msg["text"].encode()) except WebSocketDisconnect: pass diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte index 54b3722..883463b 100644 --- a/dashboard/frontend/src/routes/Terminal.svelte +++ b/dashboard/frontend/src/routes/Terminal.svelte @@ -122,6 +122,7 @@ if (d) { d.voice?.destroy(); d.ro?.disconnect(); + if (d.heartbeat) clearInterval(d.heartbeat); d.term?.element?.removeEventListener("dragover", d.handleDragOver); d.term?.element?.removeEventListener("drop", d.handleDrop); d.term?.textarea?.removeEventListener("paste", d.handlePaste); @@ -180,6 +181,10 @@ ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" }); term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data))); + const heartbeat = setInterval(() => { + if (ws.readyState === 1) ws.send("__ping__"); + }, 25000); + const handleDragOver = (e) => { e.preventDefault(); }; @@ -211,7 +216,7 @@ ro.observe(el); const voice = new VoiceSession(() => { voiceTick++; }); voice.attach(term, ws); - tabData.set(tabId, { ws, term, ro, el, voice, handleDragOver, handleDrop, handlePaste }); + tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, handleDragOver, handleDrop, handlePaste }); })(); } @@ -222,6 +227,7 @@ tabData.forEach((d) => { d.voice?.destroy(); d.ro?.disconnect(); + if (d.heartbeat) clearInterval(d.heartbeat); d.term?.element?.removeEventListener("dragover", d.handleDragOver); d.term?.element?.removeEventListener("drop", d.handleDrop); d.term?.textarea?.removeEventListener("paste", d.handlePaste); -- 2.52.0