mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
fix(webcrt): replay login banner/prompt on attach
Drain and nudge the device prompt after Netmiko login, replay bootstrap stdout when the WebSocket attaches, and surface connect errors in the terminal pane. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8e3bed562f
commit
30f2db9708
6 changed files with 87 additions and 9 deletions
|
|
@ -93,9 +93,27 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
"protocol": sess.protocol,
|
"protocol": sess.protocol,
|
||||||
"cols": sess.cols,
|
"cols": sess.cols,
|
||||||
"rows": sess.rows,
|
"rows": sess.rows,
|
||||||
|
"device_type": sess.device_type,
|
||||||
|
"vendor": sess.vendor,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Replay post-login banner/prompt so the UI is not blank until the user presses Enter.
|
||||||
|
bootstrap = bytes(sess.bootstrap_output or b"")
|
||||||
|
if bootstrap:
|
||||||
|
try:
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "stdout", "data": bootstrap.decode("utf-8", errors="replace")}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True)
|
||||||
|
else:
|
||||||
|
# Last resort: ask the device to redraw the prompt into the live reader.
|
||||||
|
try:
|
||||||
|
await asyncio.get_running_loop().run_in_executor(None, sess.write_stdin, "\r")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
|
|
||||||
async def pump_stdout() -> None:
|
async def pump_stdout() -> None:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -76,6 +77,35 @@ def map_network_cli_keys(data: str) -> str:
|
||||||
i += 1
|
i += 1
|
||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
|
def _drain_channel(conn: ConnectHandler, *, rounds: int = 10, wait: float = 0.12) -> str:
|
||||||
|
"""Read whatever is already sitting on the channel after login."""
|
||||||
|
chunks: list[str] = []
|
||||||
|
empty_streak = 0
|
||||||
|
for _ in range(max(1, rounds)):
|
||||||
|
time.sleep(wait)
|
||||||
|
try:
|
||||||
|
part = conn.read_channel()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if part:
|
||||||
|
chunks.append(str(part))
|
||||||
|
empty_streak = 0
|
||||||
|
else:
|
||||||
|
empty_streak += 1
|
||||||
|
if empty_streak >= 2 and chunks:
|
||||||
|
break
|
||||||
|
return "".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_cli_prompt(text: str) -> bool:
|
||||||
|
s = str(text or "").rstrip()
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
# Common network CLI prompts: <r1> [HUAWEI] Router# Router>
|
||||||
|
return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_now() -> datetime:
|
def _utc_now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
@ -120,6 +150,7 @@ class WebcrtSession:
|
||||||
detach_deadline: float | None = None
|
detach_deadline: float | None = None
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
close_reason: str = ""
|
close_reason: str = ""
|
||||||
|
bootstrap_output: bytes = b""
|
||||||
out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue)
|
out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue)
|
||||||
_reader: threading.Thread | None = field(default=None, repr=False)
|
_reader: threading.Thread | None = field(default=None, repr=False)
|
||||||
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||||
|
|
@ -358,9 +389,19 @@ def create_session(
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
leftover = conn.read_channel()
|
leftover = _drain_channel(conn, rounds=8, wait=0.1)
|
||||||
except Exception:
|
except Exception:
|
||||||
leftover = ""
|
leftover = ""
|
||||||
|
# Netmiko often consumes the login banner; nudge Enter once to surface the prompt.
|
||||||
|
if not _looks_like_cli_prompt(leftover):
|
||||||
|
try:
|
||||||
|
conn.write_channel("\r")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
leftover = (leftover or "") + _drain_channel(conn, rounds=8, wait=0.12)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
sess = WebcrtSession(
|
sess = WebcrtSession(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
|
@ -374,9 +415,9 @@ def create_session(
|
||||||
vendor=vendor,
|
vendor=vendor,
|
||||||
cli_keymap=cli_keymap,
|
cli_keymap=cli_keymap,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
|
bootstrap_output=str(leftover or "").encode("utf-8", errors="replace"),
|
||||||
)
|
)
|
||||||
if leftover:
|
# Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount).
|
||||||
sess.out_queue.put(str(leftover).encode("utf-8", errors="replace"))
|
|
||||||
sess.start_reader()
|
sess.start_reader()
|
||||||
|
|
||||||
with _sessions_lock:
|
with _sessions_lock:
|
||||||
|
|
|
||||||
|
|
@ -145,8 +145,11 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
self.assertEqual(out["ne_id"], "ne-hop")
|
self.assertEqual(out["ne_id"], "ne-hop")
|
||||||
sess = svc.get_session(out["session_id"])
|
sess = svc.get_session(out["session_id"])
|
||||||
assert sess is not None
|
assert sess is not None
|
||||||
|
# create_session may nudge Enter when no prompt was drained.
|
||||||
|
before = list(fake.written)
|
||||||
sess.write_stdin("\n")
|
sess.write_stdin("\n")
|
||||||
self.assertEqual(fake.written, ["\n"])
|
self.assertEqual(fake.written[len(before) :], ["\n"])
|
||||||
|
self.assertTrue(isinstance(sess.bootstrap_output, (bytes, bytearray)))
|
||||||
svc.close_session(out["session_id"], reason="test")
|
svc.close_session(out["session_id"], reason="test")
|
||||||
|
|
||||||
@patch.object(svc, "_audit")
|
@patch.object(svc, "_audit")
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,6 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
onStatusRef.current?.("open");
|
onStatusRef.current?.("open");
|
||||||
term.writeln("\x1b[32mConnected.\x1b[0m");
|
|
||||||
sendResize();
|
sendResize();
|
||||||
onReadyRef.current?.();
|
onReadyRef.current?.();
|
||||||
};
|
};
|
||||||
|
|
@ -87,6 +86,9 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
||||||
data?: string;
|
data?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
ne_name?: string;
|
||||||
|
ne_ip?: string;
|
||||||
|
protocol?: string;
|
||||||
};
|
};
|
||||||
if (msg.type === "stdout" && typeof msg.data === "string") {
|
if (msg.type === "stdout" && typeof msg.data === "string") {
|
||||||
term.write(msg.data);
|
term.write(msg.data);
|
||||||
|
|
@ -95,7 +97,8 @@ export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
|
||||||
if (msg.type === "status") {
|
if (msg.type === "status") {
|
||||||
onStatusRef.current?.(String(msg.state || ""), msg.message);
|
onStatusRef.current?.(String(msg.state || ""), msg.message);
|
||||||
if (msg.state === "connected") {
|
if (msg.state === "connected") {
|
||||||
// Server ack; keep terminal clean.
|
const where = [msg.ne_name || title, msg.ne_ip, msg.protocol].filter(Boolean).join(" · ");
|
||||||
|
term.writeln(`\x1b[90m--- session ready${where ? `: ${where}` : ""} ---\x1b[0m`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.state === "closed" || msg.state === "error") {
|
if (msg.state === "closed" || msg.state === "error") {
|
||||||
|
|
|
||||||
|
|
@ -1741,6 +1741,16 @@ pre {
|
||||||
color: #fecaca;
|
color: #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.webcrt-error-detail {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
max-width: 80%;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fee2e2;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.webcrt-statusline {
|
.webcrt-statusline {
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
background: #ececec;
|
background: #ececec;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type TermTab = {
|
||||||
wsUrl: string;
|
wsUrl: string;
|
||||||
target: CliTargetItem;
|
target: CliTargetItem;
|
||||||
status: "connecting" | "connected" | "error" | "closed";
|
status: "connecting" | "connected" | "error" | "closed";
|
||||||
|
errorMessage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function targetKey(t: Pick<CliTargetItem, "source" | "id">): string {
|
function targetKey(t: Pick<CliTargetItem, "source" | "id">): string {
|
||||||
|
|
@ -120,8 +121,9 @@ export function WebcrtPage() {
|
||||||
updateTab(key, { sessionId: sess.session_id, wsUrl, status: "connecting" });
|
updateTab(key, { sessionId: sess.session_id, wsUrl, status: "connecting" });
|
||||||
showOk(t("webcrt.opened", { name: target.name || target.ip_address }));
|
showOk(t("webcrt.opened", { name: target.name || target.ip_address }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
updateTab(key, { status: "error" });
|
const message = webcrtErrorMessage(err, t);
|
||||||
showError(webcrtErrorMessage(err, t));
|
updateTab(key, { status: "error", errorMessage: message });
|
||||||
|
showError(message);
|
||||||
} finally {
|
} finally {
|
||||||
connectingKeysRef.current.delete(key);
|
connectingKeysRef.current.delete(key);
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +346,8 @@ export function WebcrtPage() {
|
||||||
) : null}
|
) : null}
|
||||||
{tab.status === "error" && !tab.wsUrl ? (
|
{tab.status === "error" && !tab.wsUrl ? (
|
||||||
<div className="webcrt-main__placeholder webcrt-main__placeholder--error">
|
<div className="webcrt-main__placeholder webcrt-main__placeholder--error">
|
||||||
{t("webcrt.status.error")}
|
<div>{t("webcrt.status.error")}</div>
|
||||||
|
{tab.errorMessage ? <pre className="webcrt-error-detail">{tab.errorMessage}</pre> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{tab.wsUrl ? (
|
{tab.wsUrl ? (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue