diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index 840a647..38d6ed1 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -15,6 +15,8 @@ from .db import get_db from .webcrt_service import ( close_session, create_session, + detach_session, + get_session, list_sessions, mark_attached, ) @@ -151,6 +153,12 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: sess.touch() await websocket.send_json({"type": "pong"}) elif mtype == "close": + stop.set() + close_session( + session_id, + reason="client_close", + client=_client_label(websocket=websocket), + ) break except WebSocketDisconnect: _log.info("webcrt ws disconnected session=%s", session_id) @@ -163,8 +171,10 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: await reader_task except Exception: pass - close_session( - session_id, - reason="ws_disconnect", - client=_client_label(websocket=websocket), - ) + # Keep device session briefly so React remount / blip can re-attach. + if get_session(session_id) is not None: + detach_session( + session_id, + grace_sec=8.0, + client=_client_label(websocket=websocket), + ) diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py index 500ea5b..ea7d910 100644 --- a/netx_api/webcrt_service.py +++ b/netx_api/webcrt_service.py @@ -66,6 +66,7 @@ class WebcrtSession: created_at: float = field(default_factory=time.time) last_activity: float = field(default_factory=time.time) attached: bool = False + detach_deadline: float | None = None closed: bool = False close_reason: str = "" out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue) @@ -181,7 +182,7 @@ def _reaper_loop() -> None: _reap_sessions() except Exception: _log.exception("webcrt reaper failed") - time.sleep(5) + time.sleep(2) def _reap_sessions() -> None: @@ -194,7 +195,15 @@ def _reap_sessions() -> None: if sess.closed: _sessions.pop(sess.session_id, None) continue - if not sess.attached and (now - sess.created_at) > attach: + if sess.attached: + if (now - sess.last_activity) > idle: + to_close.append((sess, "idle_timeout")) + continue + # Not attached: either never attached, or briefly detached for reconnect. + if sess.detach_deadline is not None: + if now >= sess.detach_deadline: + to_close.append((sess, "detach_timeout")) + elif (now - sess.created_at) > attach: to_close.append((sess, "attach_timeout")) elif (now - sess.last_activity) > idle: to_close.append((sess, "idle_timeout")) @@ -327,14 +336,35 @@ def mark_attached(session_id: str) -> WebcrtSession: sess = get_session(session_id) if sess is None: raise HTTPException(status_code=404, detail="webcrt_session_not_found") - if sess.attached: - raise HTTPException(status_code=409, detail="webcrt_session_already_attached") + # Allow re-attach after brief WS drop (React StrictMode remount / network blip). sess.attached = True + sess.detach_deadline = None sess.touch() _audit("session_attached", session_id=session_id, ne_id=sess.ne_id, ne_ip=sess.ne_ip) return sess +def detach_session(session_id: str, *, grace_sec: float = 8.0, client: str = "") -> dict[str, Any]: + """Mark session unattached but keep device channel open briefly for reconnect.""" + sess = get_session(session_id) + if sess is None: + return {"ok": True, "session_id": session_id, "detached": False} + if sess.closed: + return {"ok": True, "session_id": session_id, "detached": False} + sess.attached = False + sess.detach_deadline = time.time() + max(1.0, float(grace_sec)) + sess.touch() + _audit( + "session_detached", + session_id=session_id, + ne_id=sess.ne_id, + ne_ip=sess.ne_ip, + grace_sec=grace_sec, + client=client or "", + ) + return {"ok": True, "session_id": session_id, "detached": True} + + def close_session(session_id: str, *, reason: str = "closed", client: str = "") -> dict[str, Any]: with _sessions_lock: sess = _sessions.pop(session_id, None) diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 4061f8c..dea8f67 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -5,11 +5,12 @@ import "@xterm/xterm/css/xterm.css"; type Props = { wsUrl: string; + title?: string; onStatus?: (state: string, message?: string) => void; onReady?: () => void; }; -export function WebTerminal({ wsUrl, onStatus, onReady }: Props) { +export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) { const hostRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); @@ -40,13 +41,26 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) { const fit = new FitAddon(); term.loadAddon(fit); term.open(host); - fit.fit(); termRef.current = term; fitRef.current = fit; + const doFit = () => { + try { + fit.fit(); + } catch { + /* ignore */ + } + }; + // Fit after layout; hidden/zero-size parents need a deferred pass. + requestAnimationFrame(() => { + doFit(); + window.setTimeout(doFit, 50); + }); + const ws = new WebSocket(wsUrl); wsRef.current = ws; onStatusRef.current?.("connecting"); + term.writeln(`\x1b[90mConnecting${title ? ` ${title}` : ""}…\x1b[0m`); const sendJson = (payload: Record) => { if (ws.readyState === WebSocket.OPEN) { @@ -55,12 +69,13 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) { }; const sendResize = () => { - fit.fit(); + doFit(); sendJson({ type: "resize", cols: term.cols, rows: term.rows }); }; ws.onopen = () => { onStatusRef.current?.("open"); + term.writeln("\x1b[32mConnected.\x1b[0m"); sendResize(); onReadyRef.current?.(); }; @@ -79,6 +94,10 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) { } if (msg.type === "status") { onStatusRef.current?.(String(msg.state || ""), msg.message); + if (msg.state === "connected") { + // Server ack; keep terminal clean. + return; + } if (msg.state === "closed" || msg.state === "error") { const detail = msg.message ? `: ${msg.message}` : ""; term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`); @@ -93,6 +112,7 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) { ws.onerror = () => { onStatusRef.current?.("error", "websocket_error"); + term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m"); }; ws.onclose = () => { @@ -123,10 +143,8 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) { window.removeEventListener("resize", onWinResize); ro?.disconnect(); dataDisposable.dispose(); + // Do not send {type:"close"} here — React StrictMode remounts and needs reconnect. try { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "close" })); - } ws.close(); } catch { /* ignore */ @@ -136,7 +154,7 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) { termRef.current = null; fitRef.current = null; }; - }, [wsUrl]); + }, [wsUrl, title]); return
; } diff --git a/web/src/index.css b/web/src/index.css index 91e0109..593c857 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1424,8 +1424,8 @@ pre { .webcrt-term { width: 100%; - height: min(70vh, 720px); - min-height: 360px; + height: 100%; + min-height: 240px; background: #0f172a; padding: 8px; box-sizing: border-box; @@ -1656,6 +1656,9 @@ pre { display: flex; flex-direction: column; min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; background: #808080; } @@ -1666,6 +1669,7 @@ pre { background: #6e6e6e; border-bottom: 1px solid #505050; min-height: 28px; + flex-shrink: 0; } .webcrt-tabs__item { @@ -1697,16 +1701,26 @@ pre { .webcrt-main__body { flex: 1; min-height: 0; + position: relative; background: #0f172a; } .webcrt-main__pane { - height: 100%; + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} + +.webcrt-main__pane[hidden] { + display: none !important; } .webcrt-main__pane .webcrt-term { - height: 100%; - min-height: 0; + flex: 1; + width: 100%; + height: auto !important; + min-height: 240px !important; } .webcrt-main__empty, @@ -1733,6 +1747,7 @@ pre { border-top: 1px solid #a8a8a8; font-size: 11px; color: #334155; + flex-shrink: 0; } @media (max-width: 900px) { diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 5b2ab8f..90606c4 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -350,6 +350,7 @@ export function WebcrtPage() { {tab.wsUrl ? ( { if (state === "open" || state === "connected") updateTab(tab.key, { status: "connected" }); else if (state === "error") updateTab(tab.key, { status: "error" });