From dc17f9d15ad830a34cd1e2cbc933b41270ce5a8d Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 28 May 2026 21:50:41 +0800 Subject: [PATCH] feat(ne): connect detail, Huawei/Cisco hop, Cisco hostname probe Persist full connect test logs (connect_detail) with NE UI detail modal. Add Huawei/Cisco jump CLI templates and generic CLI hop session path. Probe Cisco hostname via show configuration | include hostname (60s timeout). Co-authored-by: Cursor --- netx_api/main.py | 1 + netx_api/models.py | 1 + netx_api/ne_connect.py | 170 +++++++++++++++++++++++--- netx_api/ne_schemas.py | 1 + netx_api/ne_service.py | 9 +- netx_api/ne_session_factory.py | 64 ++++++++-- tests/test_managed_ne.py | 8 ++ web/src/components/HopProxyFields.tsx | 43 +++++-- web/src/i18n/en.ts | 18 +++ web/src/i18n/zh.ts | 17 +++ web/src/index.css | 25 ++++ web/src/pages/NePage.tsx | 96 +++++++++++++-- web/src/types.ts | 1 + web/src/utils/hopProxy.ts | 35 ++++-- web/src/utils/zteHop.ts | 31 ++++- 15 files changed, 457 insertions(+), 63 deletions(-) diff --git a/netx_api/main.py b/netx_api/main.py index 67cbc3f..ce5e889 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -749,6 +749,7 @@ def on_startup() -> None: conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_password_enc TEXT DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_command_template TEXT DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vrf VARCHAR(128) DEFAULT ''") + conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''") conn.exec_driver_sql( "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP" ) diff --git a/netx_api/models.py b/netx_api/models.py index 084c1da..a30e4d1 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -240,6 +240,7 @@ class ManagedNE(Base): enable_secret_enc: Mapped[str] = mapped_column(Text, default="") connect_status: Mapped[str] = mapped_column(String(32), default="unknown", index=True) connect_message: Mapped[str] = mapped_column(String(512), default="") + connect_detail: Mapped[str] = mapped_column(Text, default="") connect_tested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) site: Mapped[str] = mapped_column(String(256), default="") tags: Mapped[str] = mapped_column(String(512), default="") diff --git a/netx_api/ne_connect.py b/netx_api/ne_connect.py index 5a43188..e0e71ef 100644 --- a/netx_api/ne_connect.py +++ b/netx_api/ne_connect.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import re +import traceback from concurrent.futures import ThreadPoolExecutor from datetime import datetime from typing import Any @@ -15,6 +16,7 @@ from .ne_session_factory import close_netmiko_connection, open_netmiko_connectio _log = logging.getLogger("netx.ne.connect") _executor: ThreadPoolExecutor | None = None +_DETAIL_MAX = 8000 def _executor_pool() -> ThreadPoolExecutor: @@ -25,10 +27,39 @@ def _executor_pool() -> ThreadPoolExecutor: return _executor +def _truncate_detail(text: str) -> str: + return str(text or "")[:_DETAIL_MAX] + + +def _connect_context_lines(creds: dict[str, Any]) -> list[str]: + lines = [ + f"target={creds.get('ip_address')}:{creds.get('port')}/{creds.get('protocol')}", + f"device_type={creds.get('device_type')} vendor={creds.get('vendor')}", + f"username={creds.get('username')}", + ] + if creds.get("hop_enabled"): + lines.append( + "hop=" + f"enabled vendor={creds.get('hop_vendor')} " + f"host={creds.get('hop_host')}:{creds.get('hop_port')}/{creds.get('hop_protocol')} " + f"user={creds.get('hop_username')}" + ) + tpl = str(creds.get("hop_command_template") or "").strip() + if tpl: + lines.append(f"hop_command_template={tpl}") + vrf = str(creds.get("hop_vrf") or "").strip() + if vrf: + lines.append(f"hop_vrf={vrf}") + else: + lines.append("hop=disabled (direct)") + return lines + + def hostname_probe_command(device_type: str, vendor: str) -> str | None: """ Per-vendor CLI to read system name (ported from legacy connect.extract_dev_command). - ZTE: rely on login prompt / empty command path. + ZTE: rely on login prompt when no dedicated command. + Cisco: show configuration filter; Huawei: current-configuration sysname. """ dt = str(device_type or "").lower() v = str(vendor or "").lower() @@ -37,7 +68,7 @@ def hostname_probe_command(device_type: str, vendor: str) -> str | None: if "juniper" in dt or v == "juniper": return "show system host-name" if "cisco" in dt or v == "cisco": - return "show hostname" + return "show configuration | include hostname" return None @@ -68,12 +99,15 @@ def parse_hostname_from_output( return m.group(1).strip().rstrip(";") if "cisco" in dt or v == "cisco": + m = re.search(r"hostname\s+(\S+)", text, re.IGNORECASE) + if m: + return m.group(1).strip() lines = [ln.strip() for ln in text.splitlines() if ln.strip()] for ln in reversed(lines): if ln.startswith("%") or "invalid" in ln.lower(): continue token = ln.split()[0].strip("<>[]") - if token: + if token and token.lower() != "hostname": return token if "zte" in dt or v == "zte": @@ -102,7 +136,8 @@ def _clean_prompt_hostname(prompt: str) -> str | None: def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str: raw = str(exc).lower() - detail = str(exc).split("\n")[0][:480] + full = str(exc).strip() + detail = full.split("\n")[0][:480] if full else type(exc).__name__ if creds.get("hop_enabled"): hop_v = str(creds.get("hop_vendor") or "zte").lower() if "hop_credentials_incomplete" in raw or "hop_command_template_invalid" in raw: @@ -118,11 +153,50 @@ def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str: if hop_v == "linux": return "hop_connect_failed: " + detail return "hop_command_failed: " + detail + if "readtimeout" in raw.replace(" ", "") or "pattern not detected" in raw: + return "probe_command_timeout: " + detail return detail -def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None]: - """Login via Netmiko, probe hostname, return (status, message, discovered_name).""" +def _format_failure_detail(creds: dict[str, Any], exc: BaseException) -> str: + lines = _connect_context_lines(creds) + lines.append(f"result=fail") + lines.append(f"error={type(exc).__name__}: {exc}") + tb = traceback.format_exc().strip() + if tb: + lines.append("") + lines.append(tb) + return _truncate_detail("\n".join(lines)) + + +_PROBE_READ_TIMEOUT = 60 + + +def _format_success_detail( + creds: dict[str, Any], + *, + prompt: str, + command: str | None, + output: str, + hostname: str | None, + summary: str, +) -> str: + lines = _connect_context_lines(creds) + lines.append(f"result=pass summary={summary}") + if prompt: + lines.append(f"prompt={prompt}") + if command: + lines.append(f"probe_command={command}") + if output: + lines.append("probe_output:") + lines.append(output[:3000]) + if hostname: + lines.append(f"parsed_hostname={hostname}") + return _truncate_detail("\n".join(lines)) + + +def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None, str]: + """Login via Netmiko, probe hostname; return (status, message, discovered_name, detail).""" vendor = str(creds.get("vendor") or "") session_timeout = 180 if creds.get("hop_enabled") else None conn = None @@ -132,23 +206,64 @@ def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None]: command = hostname_probe_command(creds["device_type"], vendor) output = "" if command: - output = conn.send_command(command_string=command, read_timeout=30) + output = conn.send_command(command_string=command, read_timeout=_PROBE_READ_TIMEOUT) hostname = parse_hostname_from_output(creds["device_type"], vendor, output, prompt) if hostname: - return "pass", f"connected: {hostname}", hostname + msg = f"connected: {hostname}" + return ( + "pass", + msg, + hostname, + _format_success_detail( + creds, prompt=prompt, command=command, output=output, hostname=hostname, summary=msg + ), + ) if command: - return "pass", "connected (hostname not parsed)", None + msg = "connected (hostname not parsed)" + return ( + "pass", + msg, + None, + _format_success_detail(creds, prompt=prompt, command=command, output=output, hostname=None, summary=msg), + ) fallback = _clean_prompt_hostname(prompt) if fallback: - return "pass", f"connected: {fallback}", fallback - return "pass", "connected", None + msg = f"connected: {fallback}" + return ( + "pass", + msg, + fallback, + _format_success_detail( + creds, prompt=prompt, command=command, output=output, hostname=fallback, summary=msg + ), + ) + msg = "connected" + return ( + "pass", + msg, + None, + _format_success_detail(creds, prompt=prompt, command=command, output=output, hostname=None, summary=msg), + ) except Exception as exc: - return "fail", _classify_connect_error(creds, exc), None + _log.exception( + "connect probe failed target=%s hop=%s", + creds.get("ip_address"), + creds.get("hop_enabled"), + ) + msg = _classify_connect_error(creds, exc) + return "fail", msg, None, _format_failure_detail(creds, exc) finally: close_netmiko_connection(conn) -def _update_row(ne_id: str, status: str, message: str, discovered_name: str | None = None) -> None: +def _update_row( + ne_id: str, + status: str, + message: str, + discovered_name: str | None = None, + *, + detail: str = "", +) -> None: db = SessionLocal() try: row = db.get(ManagedNE, ne_id) @@ -156,6 +271,7 @@ def _update_row(ne_id: str, status: str, message: str, discovered_name: str | No return row.connect_status = status row.connect_message = str(message or "")[:500] + row.connect_detail = _truncate_detail(detail) row.connect_tested_at = datetime.utcnow() if discovered_name: row.name = discovered_name[:256] @@ -173,18 +289,38 @@ def _run_single(ne_id: str) -> None: return row.connect_status = "testing" row.connect_message = "" + row.connect_detail = "" row.updated_at = datetime.utcnow() db.commit() try: creds = get_device_credentials(row) except CredentialCryptoError as exc: - _update_row(ne_id, "fail", str(exc)) + ctx = { + "ip_address": row.ip_address, + "port": row.port, + "protocol": row.protocol, + "device_type": row.device_type, + "vendor": row.vendor, + "username": row.username, + "hop_enabled": bool(row.hop_enabled), + "hop_vendor": row.hop_vendor, + "hop_host": row.hop_host, + "hop_port": row.hop_port, + "hop_protocol": row.hop_protocol, + "hop_username": row.hop_username, + "hop_command_template": row.hop_command_template, + "hop_vrf": row.hop_vrf, + } + detail = _truncate_detail( + "\n".join(_connect_context_lines(ctx)) + f"\nresult=fail\nerror=CredentialCryptoError: {exc}" + ) + _update_row(ne_id, "fail", str(exc), detail=detail) return - status, message, discovered = _probe_device(creds) - _update_row(ne_id, status, message, discovered) + status, message, discovered, detail = _probe_device(creds) + _update_row(ne_id, status, message, discovered, detail=detail) except Exception as exc: _log.exception("connect test failed for %s", ne_id) - _update_row(ne_id, "fail", str(exc)[:480]) + _update_row(ne_id, "fail", str(exc)[:480], detail=_truncate_detail(traceback.format_exc())) finally: db.close() diff --git a/netx_api/ne_schemas.py b/netx_api/ne_schemas.py index ac94de1..f939a60 100644 --- a/netx_api/ne_schemas.py +++ b/netx_api/ne_schemas.py @@ -89,6 +89,7 @@ class ManagedNeOut(BaseModel): username: str connect_status: ConnectStatus connect_message: str + connect_detail: str = "" connect_tested_at: datetime | None tags: str remark: str diff --git a/netx_api/ne_service.py b/netx_api/ne_service.py index e541228..2273447 100644 --- a/netx_api/ne_service.py +++ b/netx_api/ne_service.py @@ -19,7 +19,7 @@ from .ne_schemas import ( ManagedNeOut, ManagedNeUpdate, ) -from .ne_session_factory import default_zte_hop_template +from .ne_session_factory import default_hop_command_template IMPORT_COLUMNS = ( "device_type", @@ -53,7 +53,7 @@ def _normalize_protocol(protocol: str) -> str: def _normalize_hop_vendor(vendor: str) -> str: v = str(vendor or "zte").strip().lower() - return v if v in ("zte", "linux") else "zte" + return v if v in ("zte", "linux", "huawei", "cisco") else "zte" def _validate_hop_on_create(body: ManagedNeCreate) -> None: @@ -123,6 +123,7 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut: username=str(row.username or ""), connect_status=status, # type: ignore[arg-type] connect_message=str(row.connect_message or "")[:500], + connect_detail=str(row.connect_detail or "")[:8000], connect_tested_at=row.connect_tested_at, tags=str(row.tags or ""), remark=str(row.remark or ""), @@ -289,8 +290,8 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d hop_vendor = _normalize_hop_vendor(hop.hop_vendor) template = str(hop.hop_command_template or "").strip() - if hop_vendor == "zte" and not template: - template = default_zte_hop_template(hop.hop_protocol, hop.hop_vrf) + if hop_vendor != "linux" and not template: + template = default_hop_command_template(hop_vendor, hop.hop_protocol, hop.hop_vrf) ne_ids = [str(x).strip() for x in ids if str(x).strip()] if not ne_ids: diff --git a/netx_api/ne_session_factory.py b/netx_api/ne_session_factory.py index 8bb8a5f..cc1bfde 100644 --- a/netx_api/ne_session_factory.py +++ b/netx_api/ne_session_factory.py @@ -1,4 +1,4 @@ -"""Netmiko session factory: direct connect, ZTE CLI hop, or Linux SSH bastion.""" +"""Netmiko session factory: direct connect, vendor CLI hop (ZTE/Huawei/Cisco), or Linux SSH bastion.""" from __future__ import annotations @@ -29,11 +29,49 @@ def default_zte_hop_template(protocol: str, vrf: str = "") -> str: return f"{cmd} {{target_ip}}" +def default_cisco_hop_template(protocol: str, vrf: str = "") -> str: + """Cisco CLI jump: ssh -vrf VRF IP; telnet IP [/vrf VRF].""" + v = str(vrf or "").strip() + if str(protocol or "ssh").strip().lower() == "telnet": + if v: + return "telnet {target_ip} /vrf {vrf}" + return "telnet {target_ip}" + if v: + return "ssh -vrf {vrf} {target_ip}" + return "ssh {target_ip}" + + +def default_huawei_hop_template(protocol: str, vrf: str = "") -> str: + """Huawei CLI jump: telnet [vpn-instance VRF] IP; stelnet = SSH.""" + v = str(vrf or "").strip() + if str(protocol or "ssh").strip().lower() == "telnet": + if v: + return "telnet vpn-instance {vrf} {target_ip}" + return "telnet {target_ip}" + if v: + return "stelnet {target_ip} -vpn-instance {vrf}" + return "stelnet {target_ip}" + + +def default_hop_command_template(vendor: str, protocol: str, vrf: str = "") -> str: + v = str(vendor or "zte").strip().lower() + if v == "huawei": + return default_huawei_hop_template(protocol, vrf) + if v == "cisco": + return default_cisco_hop_template(protocol, vrf) + return default_zte_hop_template(protocol, vrf) + + +def _hop_vendor(creds: dict[str, Any]) -> str: + return str(creds.get("hop_vendor") or "zte").strip().lower() + + def render_hop_command(template: str, creds: dict[str, Any]) -> str: """Render hop command from template using whitelisted placeholders only.""" tpl = str(template or "").strip() if not tpl or tpl in _LEGACY_HOP_TEMPLATES: - tpl = default_zte_hop_template( + tpl = default_hop_command_template( + _hop_vendor(creds), str(creds.get("hop_protocol") or "ssh"), str(creds.get("hop_vrf") or ""), ) @@ -150,7 +188,19 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str) raise TimeoutError("target_auth_timeout") -def _connect_via_zte_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: +def _hop_netmiko_device_type(vendor: str, hop_protocol: str) -> str: + v = str(vendor or "zte").strip().lower() + if v == "huawei": + base = "huawei" + elif v == "cisco": + base = "cisco_ios" + else: + base = "zte_zxros" + return normalize_netmiko_device_type(base, hop_protocol) + + +def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: + """Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth.""" hop_host = str(creds.get("hop_host") or "").strip() hop_user = str(creds.get("hop_username") or "").strip() hop_pass = str(creds.get("hop_password") or "") @@ -158,7 +208,7 @@ def _connect_via_zte_hop(creds: dict[str, Any], *, session_timeout: int | None = raise ValueError("hop_credentials_incomplete") hop_protocol = str(creds.get("hop_protocol") or "ssh") - hop_device_type = normalize_netmiko_device_type("zte_zxros", hop_protocol) + hop_device_type = _hop_netmiko_device_type(_hop_vendor(creds), hop_protocol) hop_dev = _base_connect_kwargs( device_type=hop_device_type, host=hop_host, @@ -183,10 +233,6 @@ def _connect_via_zte_hop(creds: dict[str, Any], *, session_timeout: int | None = raise -def _hop_vendor(creds: dict[str, Any]) -> str: - return str(creds.get("hop_vendor") or "zte").strip().lower() - - def _connect_via_linux_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: """SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style).""" hop_host = str(creds.get("hop_host") or "").strip() @@ -267,5 +313,5 @@ def open_netmiko_connection(creds: dict[str, Any], *, session_timeout: int | Non if creds.get("hop_enabled"): if _hop_vendor(creds) == "linux": return _connect_via_linux_hop(creds, session_timeout=session_timeout) - return _connect_via_zte_hop(creds, session_timeout=session_timeout) + return _connect_via_cli_hop(creds, session_timeout=session_timeout) return _connect_direct(creds, session_timeout=session_timeout) diff --git a/tests/test_managed_ne.py b/tests/test_managed_ne.py index 8962db3..faddb73 100644 --- a/tests/test_managed_ne.py +++ b/tests/test_managed_ne.py @@ -32,9 +32,17 @@ class ManagedNeHostnameParseTests(unittest.TestCase): out = "line1\nZXR10-PE1#" self.assertEqual(parse_hostname_from_output("zte_zxros", "ZTE", out), "ZXR10-PE1#") + def test_cisco_hostname(self): + out = "hostname R2\nR2#" + self.assertEqual(parse_hostname_from_output("cisco_ios", "Cisco", out), "R2") + def test_probe_commands(self): self.assertIn("sysname", hostname_probe_command("huawei", "Huawei") or "") self.assertEqual(hostname_probe_command("zte_zxros", "ZTE"), None) + self.assertEqual( + hostname_probe_command("cisco_ios", "Cisco"), + "show configuration | include hostname", + ) class ManagedNeCryptoTests(unittest.TestCase): diff --git a/web/src/components/HopProxyFields.tsx b/web/src/components/HopProxyFields.tsx index 5ac5884..cfefb55 100644 --- a/web/src/components/HopProxyFields.tsx +++ b/web/src/components/HopProxyFields.tsx @@ -2,10 +2,10 @@ import type { ReactNode } from "react"; import { useI18n } from "../i18n"; import { HOP_VENDORS, + defaultHopTemplate, isAutoHopTemplate, isLinuxHopVendor, patchHopVendorChange, - zteHopTemplate, type HopVendor, } from "../utils/hopProxy"; @@ -27,7 +27,7 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({ hop_protocol: "ssh", hop_username: "", hop_password: "", - hop_command_template: zteHopTemplate("ssh", ""), + hop_command_template: defaultHopTemplate("zte", "ssh", ""), hop_vrf: "", }); @@ -51,10 +51,32 @@ function applyHopTemplate( vrf: string, force = false, ): Partial { - if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_protocol, prev.hop_vrf)) { + if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) { return {}; } - return { hop_command_template: zteHopTemplate(protocol, vrf) }; + return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) }; +} + +function hopHintKey(vendor: string): string { + const v = String(vendor || "").toLowerCase(); + if (v === "linux") return "managedNe.hop.linuxHint"; + if (v === "huawei") return "managedNe.hop.huaweiHint"; + if (v === "cisco") return "managedNe.hop.ciscoHint"; + return "managedNe.hop.zteHint"; +} + +function templateHintKey(vendor: string): string { + const v = String(vendor || "").toLowerCase(); + if (v === "huawei") return "managedNe.hop.templateHintHuawei"; + if (v === "cisco") return "managedNe.hop.templateHintCisco"; + return "managedNe.hop.templateHint"; +} + +function vrfLabelKey(vendor: string): string { + const v = String(vendor || "").toLowerCase(); + if (v === "huawei") return "managedNe.hop.vpnInstance"; + if (v === "cisco") return "managedNe.hop.vrfCisco"; + return "managedNe.hop.vrf"; } type Props = { @@ -72,6 +94,7 @@ export function HopProxyFields({ }: Props) { const { t } = useI18n(); const linux = isLinuxHopVendor(value.hop_vendor); + const huawei = value.hop_vendor === "huawei"; const set = (patch: Partial) => onChange(patch); @@ -92,9 +115,7 @@ export function HopProxyFields({ ))} - - {linux ? t("managedNe.hop.linuxHint") : t("managedNe.hop.zteHint")} - + {t(hopHintKey(value.hop_vendor))} @@ -144,7 +165,7 @@ export function HopProxyFields({ {!linux ? ( <> ) : null} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 5c290f0..7e8b036 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -163,7 +163,12 @@ const en = { run: "Connectivity test", running: "Testing…", submitted: "Submitted tests for {{n}} device(s)", + retest: "Test again", }, + connectDetail: "Details", + connectDetailTitle: "Connectivity test log", + connectDetailEmpty: + "No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).", importResult: { done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)", }, @@ -187,21 +192,34 @@ const en = { enable: "Connect to target via jump host", vendor: { zte: "ZTE device (CLI jump)", + huawei: "Huawei device (CLI jump)", + cisco: "Cisco device (CLI jump)", linux: "Linux server (SSH tunnel)", }, zteHint: "Run ssh/telnet on the ZTE device to reach the target; target credentials use secondary auth.", + huaweiHint: "Run telnet / stelnet (SSH) on the Huawei hop; stelnet is SSH. Target credentials use secondary auth.", + ciscoHint: "Run Cisco ssh -vrf / telnet /vrf jump commands; target credentials use secondary auth.", linuxHint: "SSH to the Linux bastion, then direct-tcpip tunnel to target IP:port (ProxyJump-style).", host: "Jump host", port: "Jump port", protocol: "Jump protocol", + protocolSshStelnet: "ssh (stelnet)", username: "Jump username", password: "Jump password", vrf: "Mgmt VRF (optional)", + vpnInstance: "VPN-Instance (optional)", + vrfCisco: "VRF (optional, e.g. MGMT)", commandTemplate: "Jump command template", templateHint: "ZTE CLI: telnet {target_ip}, telnet {target_ip} vrf {vrf}, ssh {target_ip}, ssh {target_ip} vrf {vrf}. Auto-suggested from jump protocol/VRF; same when left blank. Target credentials via secondary auth prompts.", + templateHintHuawei: + "Huawei CLI: telnet {target_ip}, telnet vpn-instance {vrf} {target_ip}, stelnet {target_ip}, stelnet {target_ip} -vpn-instance {vrf}. SSH protocol maps to stelnet. Auto-suggested when left blank.", + templateHintCisco: + "Cisco CLI: ssh {target_ip}, ssh -vrf {vrf} {target_ip}, telnet {target_ip}, telnet {target_ip} /vrf {vrf}. Auto-suggested when left blank.", badge: { zte: "ZTE hop", + huawei: "Huawei hop", + cisco: "Cisco hop", linux: "Linux hop", }, hostRequired: "Jump host is required", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 50adcf8..6c31b8f 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -162,7 +162,11 @@ const zh = { run: "连通性测试", running: "测试中…", submitted: "已提交 {{n}} 台设备测试", + retest: "重新测试", }, + connectDetail: "详情", + connectDetailTitle: "连通性测试日志", + connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。", importResult: { done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行", }, @@ -186,21 +190,34 @@ const zh = { enable: "经跳板登录目标网元", vendor: { zte: "ZTE 设备(CLI 跳登)", + huawei: "华为设备(CLI 跳登)", + cisco: "思科设备(CLI 跳登)", linux: "Linux 服务器(SSH 隧道)", }, zteHint: "在 ZTE 设备上执行 ssh/telnet 命令跳转到目标,目标账号由二次认证输入。", + huaweiHint: "在华为设备上执行 telnet / stelnet(SSH)跳登;stelnet 即 SSH。目标账号由二次认证输入。", + ciscoHint: "在思科设备上执行 ssh -vrf / telnet /vrf 跳登,目标账号由二次认证输入。", linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。", host: "跳板地址", port: "跳板端口", protocol: "跳板协议", + protocolSshStelnet: "ssh(stelnet)", username: "跳板用户名", password: "跳板密码", vrf: "管理 VRF(可选)", + vpnInstance: "VPN-Instance(可选)", + vrfCisco: "VRF(可选,如 MGMT)", commandTemplate: "跳登命令模板", templateHint: "ZTE 常用:telnet {target_ip}、telnet {target_ip} vrf {vrf}、ssh {target_ip}、ssh {target_ip} vrf {vrf}。按跳板协议与 VRF 自动推荐;留空时后端同样规则。目标账号密码由二次认证提示输入。", + templateHintHuawei: + "华为常用:telnet {target_ip}、telnet vpn-instance {vrf} {target_ip}、stelnet {target_ip}、stelnet {target_ip} -vpn-instance {vrf}。SSH 协议对应 stelnet。留空时按协议与 VPN-Instance 自动推荐。", + templateHintCisco: + "思科常用:ssh {target_ip}、ssh -vrf {vrf} {target_ip}、telnet {target_ip}、telnet {target_ip} /vrf {vrf}。留空时按协议与 VRF 自动推荐。", badge: { zte: "ZTE跳板", + huawei: "华为跳板", + cisco: "思科跳板", linux: "Linux跳板", }, hostRequired: "请填写跳板地址", diff --git a/web/src/index.css b/web/src/index.css index 04c72d6..2de8e86 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -835,6 +835,31 @@ pre { box-shadow: 0 16px 48px rgba(15, 23, 42, 0.2); } +.modal--wide { + width: min(920px, 100%); +} + +.connect-detail-summary { + margin-left: 8px; + color: #64748b; + font-size: 13px; +} + +.connect-log { + margin: 12px 0 0; + padding: 12px; + max-height: min(52vh, 480px); + overflow: auto; + background: #0f172a; + color: #e2e8f0; + border-radius: 8px; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + .modal h3 { margin: 0 0 16px; } diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index 391a617..15b2916 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { batchApplyHopManagedNe, @@ -19,8 +19,12 @@ import { useToast } from "../hooks/useToast"; import type { ManagedNeItem } from "../types"; import { pageCount } from "../utils/display"; import { formatSystemTime } from "../utils/time"; -import { isAutoHopTemplate, patchHopVendorChange, zteHopTemplate } from "../utils/hopProxy"; -import type { HopVendor } from "../utils/hopProxy"; +import { + defaultHopTemplate, + isAutoHopTemplate, + patchHopVendorChange, + type HopVendor, +} from "../utils/hopProxy"; type FormState = { name: string; @@ -62,15 +66,15 @@ const emptyForm = (): FormState => ({ hop_protocol: "ssh", hop_username: "", hop_password: "", - hop_command_template: zteHopTemplate("ssh", ""), + hop_command_template: defaultHopTemplate("zte", "ssh", ""), hop_vrf: "", }); function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial { - if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_protocol, prev.hop_vrf)) { + if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) { return {}; } - return { hop_command_template: zteHopTemplate(protocol, vrf) }; + return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) }; } function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) { @@ -111,6 +115,7 @@ export function NePage() { const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm); const [batchHop, setBatchHop] = useState(emptyHopProxyFields); + const [connectDetailRow, setConnectDetailRow] = useState(null); const metaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, @@ -134,6 +139,20 @@ export function NePage() { }, }); + useEffect(() => { + if (!connectDetailRow) return; + const updated = listQuery.data?.items?.find((x) => x.id === connectDetailRow.id); + if (!updated) return; + if ( + updated.connect_status !== connectDetailRow.connect_status || + updated.connect_message !== connectDetailRow.connect_message || + updated.connect_detail !== connectDetailRow.connect_detail || + updated.connect_tested_at !== connectDetailRow.connect_tested_at + ) { + setConnectDetailRow(updated); + } + }, [listQuery.data, connectDetailRow]); + const total = listQuery.data?.total ?? 0; const pages = pageCount(total, pageSize); const perPage = (n: number) => t("common.perPage", { n }); @@ -279,7 +298,9 @@ export function NePage() { tags: row.tags, remark: row.remark, hop_enabled: row.hop_enabled, - hop_vendor: (row.hop_vendor === "linux" ? "linux" : "zte") as HopVendor, + hop_vendor: (["linux", "huawei", "cisco", "zte"].includes(row.hop_vendor) + ? row.hop_vendor + : "zte") as HopVendor, hop_host: row.hop_host, hop_port: row.hop_port, hop_protocol: row.hop_protocol, @@ -287,11 +308,12 @@ export function NePage() { hop_password: "", hop_command_template: isAutoHopTemplate( row.hop_command_template, + row.hop_vendor, row.hop_protocol, row.hop_vrf, ) - ? zteHopTemplate(row.hop_protocol, row.hop_vrf) - : row.hop_command_template || zteHopTemplate(row.hop_protocol, row.hop_vrf), + ? defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf) + : row.hop_command_template || defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf), hop_vrf: row.hop_vrf, }); setModalOpen(true); @@ -466,7 +488,9 @@ export function NePage() { className="table-tag" title={`${row.hop_host}:${row.hop_port} (${row.hop_vendor})`} > - {t(`managedNe.hop.badge.${row.hop_vendor === "linux" ? "linux" : "zte"}`)} + {t( + `managedNe.hop.badge.${["linux", "huawei", "cisco", "zte"].includes(row.hop_vendor) ? row.hop_vendor : "zte"}`, + )} ) : null} @@ -485,12 +509,20 @@ export function NePage() { : t("common.empty")} + + + + + + ) : null} ); } diff --git a/web/src/types.ts b/web/src/types.ts index 6289eb2..39115e9 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -132,6 +132,7 @@ export type ManagedNeItem = { username: string; connect_status: ConnectStatus; connect_message: string; + connect_detail: string; connect_tested_at: string | null; tags: string; remark: string; diff --git a/web/src/utils/hopProxy.ts b/web/src/utils/hopProxy.ts index 0fa9bc9..3cb4f59 100644 --- a/web/src/utils/hopProxy.ts +++ b/web/src/utils/hopProxy.ts @@ -1,26 +1,43 @@ -import { isAutoHopTemplate, zteHopTemplate } from "./zteHop"; +/** Jump-host (hop) templates per vendor. */ -export type HopVendor = "zte" | "linux"; +import { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate } from "./zteHop"; -export const HOP_VENDORS: HopVendor[] = ["zte", "linux"]; +export type HopVendor = "zte" | "huawei" | "cisco" | "linux"; + +export const HOP_VENDORS: HopVendor[] = ["zte", "huawei", "cisco", "linux"]; export function isLinuxHopVendor(vendor: string): boolean { return String(vendor || "").toLowerCase() === "linux"; } +export function isCliHopVendor(vendor: string): boolean { + const v = String(vendor || "").toLowerCase(); + return v === "zte" || v === "huawei" || v === "cisco"; +} + +export function defaultHopTemplate(vendor: string, protocol: string, vrf: string): string { + const v = String(vendor || "zte").toLowerCase(); + if (v === "huawei") return huaweiHopTemplate(protocol, vrf); + if (v === "cisco") return ciscoHopTemplate(protocol, vrf); + if (v === "linux") return ""; + return zteHopTemplate(protocol, vrf); +} + export function patchHopVendorChange( vendor: HopVendor, - prev: { hop_protocol: string; hop_vrf: string; hop_command_template: string }, + prev: { hop_protocol: string; hop_vrf: string; hop_command_template: string; hop_vendor?: string }, ): { hop_vendor: HopVendor; hop_protocol: string; hop_vrf: string; hop_command_template: string } { if (vendor === "linux") { return { hop_vendor: "linux", hop_protocol: "ssh", hop_vrf: "", hop_command_template: "" }; } + const protocol = prev.hop_protocol || "ssh"; + const vrf = prev.hop_vrf || ""; return { - hop_vendor: "zte", - hop_protocol: prev.hop_protocol || "ssh", - hop_vrf: prev.hop_vrf, - hop_command_template: zteHopTemplate(prev.hop_protocol || "ssh", prev.hop_vrf), + hop_vendor: vendor, + hop_protocol: protocol, + hop_vrf: vrf, + hop_command_template: defaultHopTemplate(vendor, protocol, vrf), }; } -export { isAutoHopTemplate, zteHopTemplate }; +export { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate }; diff --git a/web/src/utils/zteHop.ts b/web/src/utils/zteHop.ts index b5f8fa3..508e612 100644 --- a/web/src/utils/zteHop.ts +++ b/web/src/utils/zteHop.ts @@ -1,4 +1,4 @@ -/** ZTE device CLI jump commands: ssh/telnet [vrf ]. */ +/** Vendor CLI jump command templates (placeholders). */ const LEGACY_HOP_TEMPLATES = new Set([ "ssh {target_user}@{target_ip}", @@ -12,8 +12,35 @@ export function zteHopTemplate(protocol: string, vrf: string): string { return v ? `${cmd} {target_ip} vrf {vrf}` : `${cmd} {target_ip}`; } -export function isAutoHopTemplate(template: string, protocol: string, vrf: string): boolean { +/** Cisco: ssh -vrf VRF IP; telnet IP [/vrf VRF]. */ +export function ciscoHopTemplate(protocol: string, vrf: string): string { + const v = String(vrf || "").trim(); + if (String(protocol || "ssh").toLowerCase() === "telnet") { + return v ? `telnet {target_ip} /vrf {vrf}` : `telnet {target_ip}`; + } + return v ? `ssh -vrf {vrf} {target_ip}` : `ssh {target_ip}`; +} + +/** Huawei: telnet [vpn-instance VRF] IP; SSH uses stelnet. */ +export function huaweiHopTemplate(protocol: string, vrf: string): string { + const v = String(vrf || "").trim(); + if (String(protocol || "ssh").toLowerCase() === "telnet") { + return v ? `telnet vpn-instance {vrf} {target_ip}` : `telnet {target_ip}`; + } + return v ? `stelnet {target_ip} -vpn-instance {vrf}` : `stelnet {target_ip}`; +} + +export function isAutoHopTemplate( + template: string, + vendor: string, + protocol: string, + vrf: string, +): boolean { const t = String(template || "").trim(); if (!t || LEGACY_HOP_TEMPLATES.has(t)) return true; + const v = String(vendor || "zte").toLowerCase(); + if (v === "huawei") return t === huaweiHopTemplate(protocol, vrf); + if (v === "cisco") return t === ciscoHopTemplate(protocol, vrf); + if (v === "linux") return t === ""; return t === zteHopTemplate(protocol, vrf); }