From 71c5193ecda8ac2d097778b2c373f5b5d8e8c83e Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 5 Aug 2026 17:20:26 +0800 Subject: [PATCH] Open WebCRT New Session setup for LLDP placeholders. Claim the existing placeholder in place and promote source to webcrt instead of prompting for password-only auth. Co-authored-by: Cursor --- netx_api/cli_schemas.py | 3 ++ netx_api/cli_service.py | 13 ++++- netx_api/ne_service_webcrt.py | 79 ++++++++++++++++++++++++++--- netx_api/webcrt_router.py | 3 ++ tests/test_managed_ne.py | 59 +++++++++++++++++++++- web/src/i18n/en.ts | 3 ++ web/src/i18n/zh.ts | 2 + web/src/pages/WebcrtPage.tsx | 94 ++++++++++++++++++++++++++++++++--- web/src/services/api.ts | 2 + web/src/types.ts | 3 ++ 10 files changed, 243 insertions(+), 18 deletions(-) diff --git a/netx_api/cli_schemas.py b/netx_api/cli_schemas.py index bb71c49..7a7d90b 100644 --- a/netx_api/cli_schemas.py +++ b/netx_api/cli_schemas.py @@ -119,8 +119,11 @@ class CliTargetOut(BaseModel): vendor: str = "" device_type: str = "" protocol: str = "" + port: int = 0 username: str = "" has_password: bool = False hop_enabled: bool = False connect_status: str = "unknown" cli_profile_ready: bool = False + # ManagedNE.source provenance (lldp / webcrt / ume_sync / …). Empty for UME list rows. + ne_source: str = "" diff --git a/netx_api/cli_service.py b/netx_api/cli_service.py index 4b3ae54..704e95c 100644 --- a/netx_api/cli_service.py +++ b/netx_api/cli_service.py @@ -274,20 +274,29 @@ def list_cli_targets( default_proto = str(getattr(default_profile, "protocol", "") or "ssh") if default_profile else "ssh" def _managed_item(row: Any, *, list_source: str = "managed") -> dict[str, Any]: + proto = str(getattr(row, "protocol", "") or "") + try: + port = int(getattr(row, "port", 0) or 0) + except (TypeError, ValueError): + port = 0 + if port <= 0: + port = 23 if proto.lower() == "telnet" else 22 return CliTargetOut( source=list_source, id=str(row.id), ume_ne_id=None, name=str(row.name or row.ip_address), - ip_address=str(row.ip_address), + ip_address=str(row.ip_address or ""), vendor=str(row.vendor), device_type=str(row.device_type), - protocol=str(getattr(row, "protocol", "") or ""), + protocol=proto, + port=port, username=str(getattr(row, "username", "") or ""), has_password=bool(str(getattr(row, "password_enc", "") or "").strip()), hop_enabled=bool(getattr(row, "hop_enabled", False)), connect_status=str(row.connect_status), cli_profile_ready=True, + ne_source=str(getattr(row, "source", "") or ""), ).model_dump() def _ume_item(inv: UmeInventoryNE, ov: UmeCliOverride | None) -> dict[str, Any]: diff --git a/netx_api/ne_service_webcrt.py b/netx_api/ne_service_webcrt.py index 443408e..a2dfa2c 100644 --- a/netx_api/ne_service_webcrt.py +++ b/netx_api/ne_service_webcrt.py @@ -6,7 +6,7 @@ from typing import Any from fastapi import HTTPException from sqlalchemy.orm import Session -from .device_types import WEBCRT_DEVICE_TYPES, WEBCRT_NE_SOURCE +from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_DEVICE_TYPES, WEBCRT_NE_SOURCE from .models import ManagedNE from .ne_crypto import encrypt_secret from .ne_schemas import ManagedNeCreate, ManagedNeOut @@ -24,6 +24,16 @@ from .ne_service_common import ( row_to_out, ) + +def _is_webcrt_claimable(row: ManagedNE) -> bool: + """LLDP placeholders / incomplete rows (no IP) can be promoted into WebCRT sessions.""" + src = str(row.source or "").strip().lower() + if src in {LLDP_DISCOVERED_NE_SOURCE, "lldp"}: + return True + if src in {WEBCRT_NE_SOURCE, "webcrt", "ume_sync"}: + return False + return not str(row.ip_address or "").strip() + def _normalize_webcrt_device_type(device_type: str) -> str: dt = str(device_type or "").strip() low = dt.lower() @@ -145,12 +155,16 @@ def upsert_webcrt_session_host( username: str = "", password: str = "", save_password: bool = False, + ne_id: str | None = None, ) -> tuple[ManagedNeOut, str]: - """Create a WebCRT session host (linux, no hop). Always inserts a new row. + """Create a WebCRT session host, or claim an existing LLDP / incomplete ManagedNE. - Same IP is allowed; session name auto-suffixes ``(1)``, ``(2)``, … on collision. + Without ``ne_id``: always inserts a new row. Same IP is allowed; session name + auto-suffixes ``(1)``, ``(2)``, … on collision among WebCRT hosts. + + With ``ne_id``: updates that claimable row in place and sets ``source=webcrt``. Telnet never persists a password. SSH persists password only when ``save_password``. - Returns ``(ne_out, \"created\")``. + Returns ``(ne_out, \"created\" | \"updated\")``. """ _require_crypto() ip = _normalize_ip(ip_address) @@ -165,19 +179,70 @@ def upsert_webcrt_session_host( raise HTTPException(status_code=400, detail="password_required") now = _now() - display_name = _next_webcrt_session_name(db, str(name or "").strip() or ip) - + port_n = int(port or (23 if proto == "telnet" else 22)) password_enc = "" if proto == "ssh" and save_password and pwd.strip(): password_enc = encrypt_secret(pwd) + claim_id = str(ne_id or "").strip() + if claim_id: + row = db.get(ManagedNE, claim_id) + if row is None: + raise HTTPException(status_code=404, detail="managed_ne_not_found") + if not _is_webcrt_claimable(row): + raise HTTPException(status_code=400, detail="ne_not_claimable_for_webcrt") + preferred = str(name or "").strip() or str(row.name or "").strip() or ip + # Keep current name when unchanged; otherwise uniquify among other WebCRT names. + if preferred == str(row.name or "").strip(): + display_name = preferred + else: + display_name = _next_webcrt_session_name(db, preferred) + row.name = display_name + row.ip_address = ip + row.port = port_n + row.protocol = proto + row.username = user + if proto == "ssh": + if save_password and password_enc: + row.password_enc = password_enc + elif not save_password: + # One-shot auth; clear incomplete placeholder creds. + row.password_enc = "" + else: + row.password_enc = "" + row.device_type = "generic" + if not str(row.vendor or "").strip(): + row.vendor = "Other" + row.source = WEBCRT_NE_SOURCE + row.connect_status = "unknown" + row.updated_at = now + from .topology_fabric_nodes import ensure_fabric_node_for_managed + + ensure_fabric_node_for_managed(db, row) + try: + db.commit() + except Exception as exc: + db.rollback() + from sqlalchemy.exc import IntegrityError + + if isinstance(exc, IntegrityError): + raise HTTPException( + status_code=409, + detail="ip_address_conflict_restart_required", + ) from exc + raise + db.refresh(row) + return row_to_out(row), "updated" + + display_name = _next_webcrt_session_name(db, str(name or "").strip() or ip) + row = ManagedNE( name=display_name, vendor="Other", # generic → Netmiko terminal_server: SSH auth then raw PTY (no linux session prep). device_type="generic", ip_address=ip, - port=int(port or (23 if proto == "telnet" else 22)), + port=port_n, protocol=proto, username=user, password_enc=password_enc, diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index faaa4be..632d177 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -75,6 +75,8 @@ class WebcrtQuickConnectBody(BaseModel): username: str = "" password: str = "" save_password: bool = False + # When set, claim/update an existing LLDP (or incomplete) ManagedNE → source=webcrt. + ne_id: str | None = None cols: int = Field(default=80, ge=20, le=500) rows: int = Field(default=24, ge=5, le=200) encoding: str = Field(default="utf-8") @@ -190,6 +192,7 @@ def api_quick_connect( username=body.username, password=body.password, save_password=save_password, + ne_id=str(body.ne_id or "").strip() or None, ) # Pass SSH credentials as one-shot overrides (covers unsaved password + reused inventory). pwd_override: str | None = None diff --git a/tests/test_managed_ne.py b/tests/test_managed_ne.py index 568d06e..12f8c2f 100644 --- a/tests/test_managed_ne.py +++ b/tests/test_managed_ne.py @@ -12,7 +12,7 @@ from sqlalchemy.pool import StaticPool from netx_api.config import settings from netx_api.db import Base, get_db from netx_api.main import app -from netx_api.models import CliConnectProfile, ManagedNE, UmeInventoryNE # noqa: F401 — register table on Base +from netx_api.models import CliConnectProfile, ManagedNE, TopoFabricNode, UmeInventoryNE # noqa: F401 — register table on Base from netx_api.ne_connect import hostname_probe_command, parse_hostname_from_output from netx_api.ne_crypto import decrypt_secret, encrypt_secret from netx_api.cli_service import list_cli_targets @@ -348,6 +348,7 @@ class WebcrtUpsertAndTargetsTests(unittest.TestCase): ManagedNE.__table__.create(bind=self.engine, checkfirst=True) UmeInventoryNE.__table__.create(bind=self.engine, checkfirst=True) CliConnectProfile.__table__.create(bind=self.engine, checkfirst=True) + TopoFabricNode.__table__.create(bind=self.engine, checkfirst=True) self.db = sessionmaker(bind=self.engine)() def tearDown(self): @@ -516,6 +517,62 @@ class WebcrtUpsertAndTargetsTests(unittest.TestCase): self.assertEqual(c.name, "10.5.5.5 (2)") self.assertEqual(a.ip_address, b.ip_address) + def test_claim_lldp_placeholder_promotes_to_webcrt(self): + from netx_api.device_types import LLDP_DISCOVERED_NE_SOURCE + from netx_api.ne_service_common import _now + + now = _now() + row = ManagedNE( + name="SW-PEER-01", + vendor="Other", + device_type="generic", + ip_address="", + port=22, + protocol="ssh", + username="", + password_enc="", + enable_secret_enc="", + connect_status="unknown", + tags="", + remark="", + source=LLDP_DISCOVERED_NE_SOURCE, + source_ref="", + created_at=now, + updated_at=now, + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + ne_id = row.id + + out, action = upsert_webcrt_session_host( + self.db, + ne_id=ne_id, + name="SW-PEER-01", + ip_address="10.9.9.9", + port=22, + protocol="ssh", + username="admin", + password="secret", + save_password=True, + ) + self.assertEqual(action, "updated") + self.assertEqual(out.id, ne_id) + self.assertEqual(out.name, "SW-PEER-01") + self.assertEqual(out.ip_address, "10.9.9.9") + self.assertEqual(out.source, WEBCRT_NE_SOURCE) + refreshed = self.db.get(ManagedNE, ne_id) + self.assertEqual(refreshed.source, WEBCRT_NE_SOURCE) + self.assertTrue(str(refreshed.password_enc or "").strip()) + + managed = list_cli_targets(self.db, source="managed", page=1, page_size=50) + managed_ids = {x["id"] for x in managed["items"]} + self.assertNotIn(ne_id, managed_ids) + webcrt = list_cli_targets(self.db, source="webcrt", page=1, page_size=50) + hit = next(x for x in webcrt["items"] if x["id"] == ne_id) + self.assertEqual(hit["ne_source"], WEBCRT_NE_SOURCE) + self.assertEqual(hit["ip_address"], "10.9.9.9") + if __name__ == "__main__": unittest.main() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index d7d53b2..c3c2274 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1123,6 +1123,9 @@ const en = { }, newSession: { title: "New Session", + claimTitle: "Complete LLDP placeholder", + claimHint: + "Enter a reachable host. Session name defaults to the LLDP placeholder. On success, source becomes WebCRT.", connect: "Connect", connecting: "Connecting…", protocol: "Protocol", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 82f45dc..74123cd 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1117,6 +1117,8 @@ const zh = { }, newSession: { title: "新建会话", + claimTitle: "完善 LLDP 占位会话", + claimHint: "请填写可达地址;会话名默认使用 LLDP 占位名。连接成功后来源将更新为 WebCRT。", connect: "连接", connecting: "连接中…", protocol: "协议", diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index d482e1e..883b0be 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -139,6 +139,8 @@ type AuthDialogState = { host: HostForm; /** When retrying an existing tree target (ne_id known). */ target?: CliTargetItem; + /** Claim/promote this ManagedNE (LLDP placeholder) via quick-connect. */ + claimNeId?: string; errorHint?: string; }; @@ -353,6 +355,24 @@ function isInventorySsh(target: Pick): boo return String(target.protocol || "ssh").toLowerCase() !== "telnet"; } +/** LLDP placeholders / incomplete inventory rows need New Session (host/IP) before auth. */ +function needsSessionSetup( + target: Pick, +): boolean { + const listSrc = String(target.source || "").toLowerCase(); + if (listSrc === "ume") return false; + if (listSrc !== "managed" && listSrc !== "webcrt") return false; + const neSrc = String(target.ne_source || "").trim().toLowerCase(); + if (neSrc === "lldp") return true; + return !String(target.ip_address || "").trim(); +} + +function defaultPortForProtocol(protocol: "ssh" | "telnet", port?: number): number { + const n = Number(port) || 0; + if (n > 0) return n; + return protocol === "telnet" ? 23 : 22; +} + function isSessionGoneError(err: unknown): boolean { const raw = String(err).toLowerCase(); return ( @@ -746,6 +766,8 @@ export function WebcrtPage() { const [renameDialog, setRenameDialog] = useState<{ target: CliTargetItem; name: string } | null>(null); const [hostDialogOpen, setHostDialogOpen] = useState(false); const [hostForm, setHostForm] = useState(() => emptyHostForm()); + /** When set, New Session claims this ManagedNE id instead of creating a duplicate. */ + const [hostDialogClaimNeId, setHostDialogClaimNeId] = useState(null); const [authDialog, setAuthDialog] = useState(null); const [authForm, setAuthForm] = useState(() => emptyAuthForm()); const [sessionBusy, setSessionBusy] = useState(false); @@ -920,14 +942,26 @@ export function WebcrtPage() { host: { name: target.name || "", ip_address: target.ip_address || "", - port: proto === "telnet" ? 23 : 22, - protocol: proto === "telnet" ? "telnet" : "ssh", + port: defaultPortForProtocol(proto, target.port), + protocol: proto, }, target, errorHint, }); }, []); + const openSessionSetupForTarget = useCallback((target: CliTargetItem) => { + const proto = String(target.protocol || "ssh").toLowerCase() === "telnet" ? "telnet" : "ssh"; + setHostDialogClaimNeId(target.id); + setHostForm({ + name: String(target.name || "").trim(), + ip_address: String(target.ip_address || "").trim(), + port: defaultPortForProtocol(proto, target.port), + protocol: proto, + }); + setHostDialogOpen(true); + }, []); + const openTarget = useCallback( async (target: CliTargetItem, opts?: { force?: boolean }) => { const key = targetKey(target); @@ -940,6 +974,12 @@ export function WebcrtPage() { } if (connectingKeysRef.current.has(key)) return; + // LLDP placeholders / no-IP rows → New Session dialog (host + session name). + if (needsSessionSetup(target) && !opts?.force) { + openSessionSetupForTarget(target); + return; + } + // Managed / WebCRT SSH without saved password → credential popup. // Telnet stays interactive in the terminal (SecureCRT-style); UME uses shared profile. if (isInventorySsh(target) && !target.has_password && !opts?.force) { @@ -1031,7 +1071,7 @@ export function WebcrtPage() { connectingKeysRef.current.delete(key); } }, - [openAuthForTarget, showOk, showError, t, updateTab], + [openAuthForTarget, openSessionSetupForTarget, showOk, showError, t, updateTab], ); /** Re-open WS to an existing backend session (within detach grace). */ @@ -1070,6 +1110,7 @@ export function WebcrtPage() { showError(t("webcrt.newSession.ipRequired")); return; } + const claimNeId = hostDialogClaimNeId || undefined; const host: HostForm = { ...hostForm, ip_address: ip, @@ -1087,6 +1128,7 @@ export function WebcrtPage() { port: host.port, protocol: "telnet", save_password: false, + ne_id: claimNeId, cols: dims.cols, rows: dims.rows, encoding: dims.encoding, @@ -1102,10 +1144,12 @@ export function WebcrtPage() { vendor: result.ne.vendor, device_type: result.ne.device_type, protocol: result.ne.protocol || "telnet", + port: host.port, username: result.ne.username || "", has_password: false, connect_status: result.ne.connect_status || "unknown", cli_profile_ready: true, + ne_source: "webcrt", }; attachSessionResult( target, @@ -1116,6 +1160,7 @@ export function WebcrtPage() { ); setHostDialogOpen(false); setHostForm(emptyHostForm()); + setHostDialogClaimNeId(null); setSource(listSource === "managed" ? "managed" : "webcrt"); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); } catch (err) { @@ -1128,8 +1173,16 @@ export function WebcrtPage() { } setHostDialogOpen(false); setAuthForm(emptyAuthForm()); - setAuthDialog({ mode: "quick", host }); - }, [attachSessionResult, hostForm, queryClient, sessionDims, showError, t]); + setAuthDialog({ mode: "quick", host, claimNeId }); + }, [ + attachSessionResult, + hostDialogClaimNeId, + hostForm, + queryClient, + sessionDims, + showError, + t, + ]); const submitAuthDialog = useCallback(async () => { if (!authDialog) return; @@ -1192,6 +1245,7 @@ export function WebcrtPage() { username, password: authForm.password, save_password: authForm.savePassword, + ne_id: authDialog.claimNeId, cols: dims.cols, rows: dims.rows, encoding: dims.encoding, @@ -1206,10 +1260,12 @@ export function WebcrtPage() { vendor: result.ne.vendor, device_type: result.ne.device_type, protocol: result.ne.protocol || "ssh", + port: host.port || 22, username: result.ne.username || username, has_password: authForm.savePassword, connect_status: result.ne.connect_status || "unknown", cli_profile_ready: true, + ne_source: "webcrt", }; attachSessionResult( target, @@ -1220,6 +1276,7 @@ export function WebcrtPage() { ); setAuthDialog(null); setAuthForm(emptyAuthForm()); + setHostDialogClaimNeId(null); setSource("webcrt"); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); } catch (err) { @@ -1247,6 +1304,7 @@ export function WebcrtPage() { errorHint: message, }); setAuthForm((prev) => ({ ...prev, username, password: "" })); + setHostDialogClaimNeId(null); setSource("webcrt"); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); } else { @@ -1902,11 +1960,13 @@ export function WebcrtPage() { vendor: row.vendor, device_type: row.device_type, protocol: row.protocol || "ssh", + port: row.port, username: row.username || "", has_password: Boolean(row.has_password), hop_enabled: Boolean(row.hop_enabled), connect_status: row.connect_status, cli_profile_ready: true, + ne_source: row.source || "", }); } } catch (err) { @@ -2046,6 +2106,7 @@ export function WebcrtPage() { type="button" className="webcrt-sidebar__new-btn" onClick={() => { + setHostDialogClaimNeId(null); setHostForm(emptyHostForm()); setHostDialogOpen(true); }} @@ -3333,7 +3394,10 @@ export function WebcrtPage() { className="modal-backdrop" role="presentation" onClick={() => { - if (!sessionBusy) setHostDialogOpen(false); + if (!sessionBusy) { + setHostDialogOpen(false); + setHostDialogClaimNeId(null); + } }} >
e.stopPropagation()} > -

{t("webcrt.newSession.title")}

+

+ {hostDialogClaimNeId + ? t("webcrt.newSession.claimTitle") + : t("webcrt.newSession.title")} +

+ {hostDialogClaimNeId ? ( +

{t("webcrt.newSession.claimHint")}

+ ) : null}
-