mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
fix(webcrt): keep session across WS remount and fix blank terminal
Allow brief WebSocket re-attach after React StrictMode cleanup, ensure terminal pane has layout height, and show connect status in xterm. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
873f2bfe34
commit
183cb7eec9
5 changed files with 95 additions and 21 deletions
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(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<string, unknown>) => {
|
||||
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 <div className="webcrt-term" ref={hostRef} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ export function WebcrtPage() {
|
|||
{tab.wsUrl ? (
|
||||
<WebTerminal
|
||||
wsUrl={tab.wsUrl}
|
||||
title={tab.target.ip_address || tab.target.name}
|
||||
onStatus={(state) => {
|
||||
if (state === "open" || state === "connected") updateTab(tab.key, { status: "connected" });
|
||||
else if (state === "error") updateTab(tab.key, { status: "error" });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue