mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
5821d08d63
commit
71c5193ecd
10 changed files with 243 additions and 18 deletions
|
|
@ -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 = ""
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -1117,6 +1117,8 @@ const zh = {
|
||||||
},
|
},
|
||||||
newSession: {
|
newSession: {
|
||||||
title: "新建会话",
|
title: "新建会话",
|
||||||
|
claimTitle: "完善 LLDP 占位会话",
|
||||||
|
claimHint: "请填写可达地址;会话名默认使用 LLDP 占位名。连接成功后来源将更新为 WebCRT。",
|
||||||
connect: "连接",
|
connect: "连接",
|
||||||
connecting: "连接中…",
|
connecting: "连接中…",
|
||||||
protocol: "协议",
|
protocol: "协议",
|
||||||
|
|
|
||||||
|
|
@ -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()}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue