fix: keep terminal sessions alive
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m23s

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) <noreply@anthropic.com>
This commit is contained in:
Gan, Jimmy
2026-03-06 16:11:43 +08:00
parent aaabb8a373
commit c9f06fbbbb
2 changed files with 11 additions and 1 deletions
+4
View File
@@ -72,6 +72,8 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
conn = await asyncssh.connect( conn = await asyncssh.connect(
h["host"], username=h["user"], h["host"], username=h["user"],
client_keys=[h["key"]], known_hosts=_known_hosts, client_keys=[h["key"]], known_hosts=_known_hosts,
keepalive_interval=30,
keepalive_count_max=3,
) )
except Exception: except Exception:
_active_sessions.discard(session_id) _active_sessions.discard(session_id)
@@ -108,6 +110,8 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
else: else:
process.stdin.write(data) process.stdin.write(data)
elif "text" in msg and msg["text"]: elif "text" in msg and msg["text"]:
if msg["text"] == "__ping__":
continue
process.stdin.write(msg["text"].encode()) process.stdin.write(msg["text"].encode())
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
@@ -122,6 +122,7 @@
if (d) { if (d) {
d.voice?.destroy(); d.voice?.destroy();
d.ro?.disconnect(); d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat);
d.term?.element?.removeEventListener("dragover", d.handleDragOver); d.term?.element?.removeEventListener("dragover", d.handleDragOver);
d.term?.element?.removeEventListener("drop", d.handleDrop); d.term?.element?.removeEventListener("drop", d.handleDrop);
d.term?.textarea?.removeEventListener("paste", d.handlePaste); d.term?.textarea?.removeEventListener("paste", d.handlePaste);
@@ -180,6 +181,10 @@
ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" }); ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" });
term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data))); 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) => { const handleDragOver = (e) => {
e.preventDefault(); e.preventDefault();
}; };
@@ -211,7 +216,7 @@
ro.observe(el); ro.observe(el);
const voice = new VoiceSession(() => { voiceTick++; }); const voice = new VoiceSession(() => { voiceTick++; });
voice.attach(term, ws); 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) => { tabData.forEach((d) => {
d.voice?.destroy(); d.voice?.destroy();
d.ro?.disconnect(); d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat);
d.term?.element?.removeEventListener("dragover", d.handleDragOver); d.term?.element?.removeEventListener("dragover", d.handleDragOver);
d.term?.element?.removeEventListener("drop", d.handleDrop); d.term?.element?.removeEventListener("drop", d.handleDrop);
d.term?.textarea?.removeEventListener("paste", d.handlePaste); d.term?.textarea?.removeEventListener("paste", d.handlePaste);