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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 17:20:26 +08:00
parent 5821d08d63
commit 71c5193ecd
10 changed files with 243 additions and 18 deletions

View file

@ -119,8 +119,11 @@ class CliTargetOut(BaseModel):
vendor: str = "" vendor: str = ""
device_type: str = "" device_type: str = ""
protocol: str = "" protocol: str = ""
port: int = 0
username: str = "" username: str = ""
has_password: bool = False has_password: bool = False
hop_enabled: bool = False hop_enabled: bool = False
connect_status: str = "unknown" connect_status: str = "unknown"
cli_profile_ready: bool = False cli_profile_ready: bool = False
# ManagedNE.source provenance (lldp / webcrt / ume_sync / …). Empty for UME list rows.
ne_source: str = ""

View file

@ -274,20 +274,29 @@ def list_cli_targets(
default_proto = str(getattr(default_profile, "protocol", "") or "ssh") if default_profile else "ssh" 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]: 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( return CliTargetOut(
source=list_source, source=list_source,
id=str(row.id), id=str(row.id),
ume_ne_id=None, ume_ne_id=None,
name=str(row.name or row.ip_address), 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), vendor=str(row.vendor),
device_type=str(row.device_type), device_type=str(row.device_type),
protocol=str(getattr(row, "protocol", "") or ""), protocol=proto,
port=port,
username=str(getattr(row, "username", "") or ""), username=str(getattr(row, "username", "") or ""),
has_password=bool(str(getattr(row, "password_enc", "") or "").strip()), has_password=bool(str(getattr(row, "password_enc", "") or "").strip()),
hop_enabled=bool(getattr(row, "hop_enabled", False)), hop_enabled=bool(getattr(row, "hop_enabled", False)),
connect_status=str(row.connect_status), connect_status=str(row.connect_status),
cli_profile_ready=True, cli_profile_ready=True,
ne_source=str(getattr(row, "source", "") or ""),
).model_dump() ).model_dump()
def _ume_item(inv: UmeInventoryNE, ov: UmeCliOverride | None) -> dict[str, Any]: def _ume_item(inv: UmeInventoryNE, ov: UmeCliOverride | None) -> dict[str, Any]:

View file

@ -6,7 +6,7 @@ from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session 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 .models import ManagedNE
from .ne_crypto import encrypt_secret from .ne_crypto import encrypt_secret
from .ne_schemas import ManagedNeCreate, ManagedNeOut from .ne_schemas import ManagedNeCreate, ManagedNeOut
@ -24,6 +24,16 @@ from .ne_service_common import (
row_to_out, 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: def _normalize_webcrt_device_type(device_type: str) -> str:
dt = str(device_type or "").strip() dt = str(device_type or "").strip()
low = dt.lower() low = dt.lower()
@ -145,12 +155,16 @@ def upsert_webcrt_session_host(
username: str = "", username: str = "",
password: str = "", password: str = "",
save_password: bool = False, save_password: bool = False,
ne_id: str | None = None,
) -> tuple[ManagedNeOut, str]: ) -> 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``. Telnet never persists a password. SSH persists password only when ``save_password``.
Returns ``(ne_out, \"created\")``. Returns ``(ne_out, \"created\" | \"updated\")``.
""" """
_require_crypto() _require_crypto()
ip = _normalize_ip(ip_address) ip = _normalize_ip(ip_address)
@ -165,19 +179,70 @@ def upsert_webcrt_session_host(
raise HTTPException(status_code=400, detail="password_required") raise HTTPException(status_code=400, detail="password_required")
now = _now() 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 = "" password_enc = ""
if proto == "ssh" and save_password and pwd.strip(): if proto == "ssh" and save_password and pwd.strip():
password_enc = encrypt_secret(pwd) 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( row = ManagedNE(
name=display_name, name=display_name,
vendor="Other", vendor="Other",
# generic → Netmiko terminal_server: SSH auth then raw PTY (no linux session prep). # generic → Netmiko terminal_server: SSH auth then raw PTY (no linux session prep).
device_type="generic", device_type="generic",
ip_address=ip, ip_address=ip,
port=int(port or (23 if proto == "telnet" else 22)), port=port_n,
protocol=proto, protocol=proto,
username=user, username=user,
password_enc=password_enc, password_enc=password_enc,

View file

@ -75,6 +75,8 @@ class WebcrtQuickConnectBody(BaseModel):
username: str = "" username: str = ""
password: str = "" password: str = ""
save_password: bool = False 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) cols: int = Field(default=80, ge=20, le=500)
rows: int = Field(default=24, ge=5, le=200) rows: int = Field(default=24, ge=5, le=200)
encoding: str = Field(default="utf-8") encoding: str = Field(default="utf-8")
@ -190,6 +192,7 @@ def api_quick_connect(
username=body.username, username=body.username,
password=body.password, password=body.password,
save_password=save_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). # Pass SSH credentials as one-shot overrides (covers unsaved password + reused inventory).
pwd_override: str | None = None pwd_override: str | None = None

View file

@ -12,7 +12,7 @@ from sqlalchemy.pool import StaticPool
from netx_api.config import settings from netx_api.config import settings
from netx_api.db import Base, get_db from netx_api.db import Base, get_db
from netx_api.main import app 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_connect import hostname_probe_command, parse_hostname_from_output
from netx_api.ne_crypto import decrypt_secret, encrypt_secret from netx_api.ne_crypto import decrypt_secret, encrypt_secret
from netx_api.cli_service import list_cli_targets 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) ManagedNE.__table__.create(bind=self.engine, checkfirst=True)
UmeInventoryNE.__table__.create(bind=self.engine, checkfirst=True) UmeInventoryNE.__table__.create(bind=self.engine, checkfirst=True)
CliConnectProfile.__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)() self.db = sessionmaker(bind=self.engine)()
def tearDown(self): def tearDown(self):
@ -516,6 +517,62 @@ class WebcrtUpsertAndTargetsTests(unittest.TestCase):
self.assertEqual(c.name, "10.5.5.5 (2)") self.assertEqual(c.name, "10.5.5.5 (2)")
self.assertEqual(a.ip_address, b.ip_address) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -1123,6 +1123,9 @@ const en = {
}, },
newSession: { newSession: {
title: "New Session", 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", connect: "Connect",
connecting: "Connecting…", connecting: "Connecting…",
protocol: "Protocol", protocol: "Protocol",

View file

@ -1117,6 +1117,8 @@ const zh = {
}, },
newSession: { newSession: {
title: "新建会话", title: "新建会话",
claimTitle: "完善 LLDP 占位会话",
claimHint: "请填写可达地址;会话名默认使用 LLDP 占位名。连接成功后来源将更新为 WebCRT。",
connect: "连接", connect: "连接",
connecting: "连接中…", connecting: "连接中…",
protocol: "协议", protocol: "协议",

View file

@ -139,6 +139,8 @@ type AuthDialogState = {
host: HostForm; host: HostForm;
/** When retrying an existing tree target (ne_id known). */ /** When retrying an existing tree target (ne_id known). */
target?: CliTargetItem; target?: CliTargetItem;
/** Claim/promote this ManagedNE (LLDP placeholder) via quick-connect. */
claimNeId?: string;
errorHint?: string; errorHint?: string;
}; };
@ -353,6 +355,24 @@ function isInventorySsh(target: Pick<CliTargetItem, "source" | "protocol">): boo
return String(target.protocol || "ssh").toLowerCase() !== "telnet"; return String(target.protocol || "ssh").toLowerCase() !== "telnet";
} }
/** LLDP placeholders / incomplete inventory rows need New Session (host/IP) before auth. */
function needsSessionSetup(
target: Pick<CliTargetItem, "source" | "ne_source" | "ip_address">,
): 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 { function isSessionGoneError(err: unknown): boolean {
const raw = String(err).toLowerCase(); const raw = String(err).toLowerCase();
return ( return (
@ -746,6 +766,8 @@ export function WebcrtPage() {
const [renameDialog, setRenameDialog] = useState<{ target: CliTargetItem; name: string } | null>(null); const [renameDialog, setRenameDialog] = useState<{ target: CliTargetItem; name: string } | null>(null);
const [hostDialogOpen, setHostDialogOpen] = useState(false); const [hostDialogOpen, setHostDialogOpen] = useState(false);
const [hostForm, setHostForm] = useState<HostForm>(() => emptyHostForm()); const [hostForm, setHostForm] = useState<HostForm>(() => emptyHostForm());
/** When set, New Session claims this ManagedNE id instead of creating a duplicate. */
const [hostDialogClaimNeId, setHostDialogClaimNeId] = useState<string | null>(null);
const [authDialog, setAuthDialog] = useState<AuthDialogState | null>(null); const [authDialog, setAuthDialog] = useState<AuthDialogState | null>(null);
const [authForm, setAuthForm] = useState<AuthForm>(() => emptyAuthForm()); const [authForm, setAuthForm] = useState<AuthForm>(() => emptyAuthForm());
const [sessionBusy, setSessionBusy] = useState(false); const [sessionBusy, setSessionBusy] = useState(false);
@ -920,14 +942,26 @@ export function WebcrtPage() {
host: { host: {
name: target.name || "", name: target.name || "",
ip_address: target.ip_address || "", ip_address: target.ip_address || "",
port: proto === "telnet" ? 23 : 22, port: defaultPortForProtocol(proto, target.port),
protocol: proto === "telnet" ? "telnet" : "ssh", protocol: proto,
}, },
target, target,
errorHint, 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( const openTarget = useCallback(
async (target: CliTargetItem, opts?: { force?: boolean }) => { async (target: CliTargetItem, opts?: { force?: boolean }) => {
const key = targetKey(target); const key = targetKey(target);
@ -940,6 +974,12 @@ export function WebcrtPage() {
} }
if (connectingKeysRef.current.has(key)) return; 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. // Managed / WebCRT SSH without saved password → credential popup.
// Telnet stays interactive in the terminal (SecureCRT-style); UME uses shared profile. // Telnet stays interactive in the terminal (SecureCRT-style); UME uses shared profile.
if (isInventorySsh(target) && !target.has_password && !opts?.force) { if (isInventorySsh(target) && !target.has_password && !opts?.force) {
@ -1031,7 +1071,7 @@ export function WebcrtPage() {
connectingKeysRef.current.delete(key); 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). */ /** Re-open WS to an existing backend session (within detach grace). */
@ -1070,6 +1110,7 @@ export function WebcrtPage() {
showError(t("webcrt.newSession.ipRequired")); showError(t("webcrt.newSession.ipRequired"));
return; return;
} }
const claimNeId = hostDialogClaimNeId || undefined;
const host: HostForm = { const host: HostForm = {
...hostForm, ...hostForm,
ip_address: ip, ip_address: ip,
@ -1087,6 +1128,7 @@ export function WebcrtPage() {
port: host.port, port: host.port,
protocol: "telnet", protocol: "telnet",
save_password: false, save_password: false,
ne_id: claimNeId,
cols: dims.cols, cols: dims.cols,
rows: dims.rows, rows: dims.rows,
encoding: dims.encoding, encoding: dims.encoding,
@ -1102,10 +1144,12 @@ export function WebcrtPage() {
vendor: result.ne.vendor, vendor: result.ne.vendor,
device_type: result.ne.device_type, device_type: result.ne.device_type,
protocol: result.ne.protocol || "telnet", protocol: result.ne.protocol || "telnet",
port: host.port,
username: result.ne.username || "", username: result.ne.username || "",
has_password: false, has_password: false,
connect_status: result.ne.connect_status || "unknown", connect_status: result.ne.connect_status || "unknown",
cli_profile_ready: true, cli_profile_ready: true,
ne_source: "webcrt",
}; };
attachSessionResult( attachSessionResult(
target, target,
@ -1116,6 +1160,7 @@ export function WebcrtPage() {
); );
setHostDialogOpen(false); setHostDialogOpen(false);
setHostForm(emptyHostForm()); setHostForm(emptyHostForm());
setHostDialogClaimNeId(null);
setSource(listSource === "managed" ? "managed" : "webcrt"); setSource(listSource === "managed" ? "managed" : "webcrt");
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
} catch (err) { } catch (err) {
@ -1128,8 +1173,16 @@ export function WebcrtPage() {
} }
setHostDialogOpen(false); setHostDialogOpen(false);
setAuthForm(emptyAuthForm()); setAuthForm(emptyAuthForm());
setAuthDialog({ mode: "quick", host }); setAuthDialog({ mode: "quick", host, claimNeId });
}, [attachSessionResult, hostForm, queryClient, sessionDims, showError, t]); }, [
attachSessionResult,
hostDialogClaimNeId,
hostForm,
queryClient,
sessionDims,
showError,
t,
]);
const submitAuthDialog = useCallback(async () => { const submitAuthDialog = useCallback(async () => {
if (!authDialog) return; if (!authDialog) return;
@ -1192,6 +1245,7 @@ export function WebcrtPage() {
username, username,
password: authForm.password, password: authForm.password,
save_password: authForm.savePassword, save_password: authForm.savePassword,
ne_id: authDialog.claimNeId,
cols: dims.cols, cols: dims.cols,
rows: dims.rows, rows: dims.rows,
encoding: dims.encoding, encoding: dims.encoding,
@ -1206,10 +1260,12 @@ export function WebcrtPage() {
vendor: result.ne.vendor, vendor: result.ne.vendor,
device_type: result.ne.device_type, device_type: result.ne.device_type,
protocol: result.ne.protocol || "ssh", protocol: result.ne.protocol || "ssh",
port: host.port || 22,
username: result.ne.username || username, username: result.ne.username || username,
has_password: authForm.savePassword, has_password: authForm.savePassword,
connect_status: result.ne.connect_status || "unknown", connect_status: result.ne.connect_status || "unknown",
cli_profile_ready: true, cli_profile_ready: true,
ne_source: "webcrt",
}; };
attachSessionResult( attachSessionResult(
target, target,
@ -1220,6 +1276,7 @@ export function WebcrtPage() {
); );
setAuthDialog(null); setAuthDialog(null);
setAuthForm(emptyAuthForm()); setAuthForm(emptyAuthForm());
setHostDialogClaimNeId(null);
setSource("webcrt"); setSource("webcrt");
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
} catch (err) { } catch (err) {
@ -1247,6 +1304,7 @@ export function WebcrtPage() {
errorHint: message, errorHint: message,
}); });
setAuthForm((prev) => ({ ...prev, username, password: "" })); setAuthForm((prev) => ({ ...prev, username, password: "" }));
setHostDialogClaimNeId(null);
setSource("webcrt"); setSource("webcrt");
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
} else { } else {
@ -1902,11 +1960,13 @@ export function WebcrtPage() {
vendor: row.vendor, vendor: row.vendor,
device_type: row.device_type, device_type: row.device_type,
protocol: row.protocol || "ssh", protocol: row.protocol || "ssh",
port: row.port,
username: row.username || "", username: row.username || "",
has_password: Boolean(row.has_password), has_password: Boolean(row.has_password),
hop_enabled: Boolean(row.hop_enabled), hop_enabled: Boolean(row.hop_enabled),
connect_status: row.connect_status, connect_status: row.connect_status,
cli_profile_ready: true, cli_profile_ready: true,
ne_source: row.source || "",
}); });
} }
} catch (err) { } catch (err) {
@ -2046,6 +2106,7 @@ export function WebcrtPage() {
type="button" type="button"
className="webcrt-sidebar__new-btn" className="webcrt-sidebar__new-btn"
onClick={() => { onClick={() => {
setHostDialogClaimNeId(null);
setHostForm(emptyHostForm()); setHostForm(emptyHostForm());
setHostDialogOpen(true); setHostDialogOpen(true);
}} }}
@ -3333,7 +3394,10 @@ export function WebcrtPage() {
className="modal-backdrop" className="modal-backdrop"
role="presentation" role="presentation"
onClick={() => { onClick={() => {
if (!sessionBusy) setHostDialogOpen(false); if (!sessionBusy) {
setHostDialogOpen(false);
setHostDialogClaimNeId(null);
}
}} }}
> >
<div <div
@ -3342,7 +3406,14 @@ export function WebcrtPage() {
aria-labelledby="webcrt-new-session-title" aria-labelledby="webcrt-new-session-title"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 id="webcrt-new-session-title">{t("webcrt.newSession.title")}</h3> <h3 id="webcrt-new-session-title">
{hostDialogClaimNeId
? t("webcrt.newSession.claimTitle")
: t("webcrt.newSession.title")}
</h3>
{hostDialogClaimNeId ? (
<p className="form-hint">{t("webcrt.newSession.claimHint")}</p>
) : null}
<div className="form-grid"> <div className="form-grid">
<label> <label>
<FormLabel required>{t("webcrt.newSession.protocol")}</FormLabel> <FormLabel required>{t("webcrt.newSession.protocol")}</FormLabel>
@ -3394,7 +3465,14 @@ export function WebcrtPage() {
</label> </label>
</div> </div>
<div className="modal__actions"> <div className="modal__actions">
<button type="button" disabled={sessionBusy} onClick={() => setHostDialogOpen(false)}> <button
type="button"
disabled={sessionBusy}
onClick={() => {
setHostDialogOpen(false);
setHostDialogClaimNeId(null);
}}
>
{t("webcrt.sessionOptionsCancel")} {t("webcrt.sessionOptionsCancel")}
</button> </button>
<button type="button" disabled={sessionBusy} onClick={() => submitHostDialog()}> <button type="button" disabled={sessionBusy} onClick={() => submitHostDialog()}>

View file

@ -645,6 +645,8 @@ export const quickConnectWebcrtSession = (body: {
username?: string; username?: string;
password?: string; password?: string;
save_password?: boolean; save_password?: boolean;
/** Claim existing LLDP / incomplete ManagedNE → promote to webcrt. */
ne_id?: string;
cols?: number; cols?: number;
rows?: number; rows?: number;
encoding?: string; encoding?: string;

View file

@ -262,11 +262,14 @@ export type CliTargetItem = {
vendor?: string; vendor?: string;
device_type?: string; device_type?: string;
protocol?: string; protocol?: string;
port?: number;
username?: string; username?: string;
has_password?: boolean; has_password?: boolean;
hop_enabled?: boolean; hop_enabled?: boolean;
connect_status: string; connect_status: string;
cli_profile_ready?: boolean; cli_profile_ready?: boolean;
/** ManagedNE.source (lldp / webcrt / ume_sync / …). */
ne_source?: string;
}; };
export type CliTargetListResponse = { export type CliTargetListResponse = {