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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-28 21:50:41 +08:00
parent 778442dc57
commit dc17f9d15a
15 changed files with 457 additions and 63 deletions

View file

@ -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_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_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 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( conn.exec_driver_sql(
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP" "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP"
) )

View file

@ -240,6 +240,7 @@ class ManagedNE(Base):
enable_secret_enc: Mapped[str] = mapped_column(Text, default="") enable_secret_enc: Mapped[str] = mapped_column(Text, default="")
connect_status: Mapped[str] = mapped_column(String(32), default="unknown", index=True) connect_status: Mapped[str] = mapped_column(String(32), default="unknown", index=True)
connect_message: Mapped[str] = mapped_column(String(512), default="") 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) connect_tested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
site: Mapped[str] = mapped_column(String(256), default="") site: Mapped[str] = mapped_column(String(256), default="")
tags: Mapped[str] = mapped_column(String(512), default="") tags: Mapped[str] = mapped_column(String(512), default="")

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import logging import logging
import re import re
import traceback
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
from typing import Any 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") _log = logging.getLogger("netx.ne.connect")
_executor: ThreadPoolExecutor | None = None _executor: ThreadPoolExecutor | None = None
_DETAIL_MAX = 8000
def _executor_pool() -> ThreadPoolExecutor: def _executor_pool() -> ThreadPoolExecutor:
@ -25,10 +27,39 @@ def _executor_pool() -> ThreadPoolExecutor:
return _executor 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: 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). 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() dt = str(device_type or "").lower()
v = str(vendor 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": if "juniper" in dt or v == "juniper":
return "show system host-name" return "show system host-name"
if "cisco" in dt or v == "cisco": if "cisco" in dt or v == "cisco":
return "show hostname" return "show configuration | include hostname"
return None return None
@ -68,12 +99,15 @@ def parse_hostname_from_output(
return m.group(1).strip().rstrip(";") return m.group(1).strip().rstrip(";")
if "cisco" in dt or v == "cisco": 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()] lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
for ln in reversed(lines): for ln in reversed(lines):
if ln.startswith("%") or "invalid" in ln.lower(): if ln.startswith("%") or "invalid" in ln.lower():
continue continue
token = ln.split()[0].strip("<>[]") token = ln.split()[0].strip("<>[]")
if token: if token and token.lower() != "hostname":
return token return token
if "zte" in dt or v == "zte": 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: def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str:
raw = str(exc).lower() 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"): if creds.get("hop_enabled"):
hop_v = str(creds.get("hop_vendor") or "zte").lower() hop_v = str(creds.get("hop_vendor") or "zte").lower()
if "hop_credentials_incomplete" in raw or "hop_command_template_invalid" in raw: 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": if hop_v == "linux":
return "hop_connect_failed: " + detail return "hop_connect_failed: " + detail
return "hop_command_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 return detail
def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None]: def _format_failure_detail(creds: dict[str, Any], exc: BaseException) -> str:
"""Login via Netmiko, probe hostname, return (status, message, discovered_name).""" 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 "") vendor = str(creds.get("vendor") or "")
session_timeout = 180 if creds.get("hop_enabled") else None session_timeout = 180 if creds.get("hop_enabled") else None
conn = 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) command = hostname_probe_command(creds["device_type"], vendor)
output = "" output = ""
if command: 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) hostname = parse_hostname_from_output(creds["device_type"], vendor, output, prompt)
if hostname: 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: 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) fallback = _clean_prompt_hostname(prompt)
if fallback: if fallback:
return "pass", f"connected: {fallback}", fallback msg = f"connected: {fallback}"
return "pass", "connected", None 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: 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: finally:
close_netmiko_connection(conn) 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() db = SessionLocal()
try: try:
row = db.get(ManagedNE, ne_id) 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 return
row.connect_status = status row.connect_status = status
row.connect_message = str(message or "")[:500] row.connect_message = str(message or "")[:500]
row.connect_detail = _truncate_detail(detail)
row.connect_tested_at = datetime.utcnow() row.connect_tested_at = datetime.utcnow()
if discovered_name: if discovered_name:
row.name = discovered_name[:256] row.name = discovered_name[:256]
@ -173,18 +289,38 @@ def _run_single(ne_id: str) -> None:
return return
row.connect_status = "testing" row.connect_status = "testing"
row.connect_message = "" row.connect_message = ""
row.connect_detail = ""
row.updated_at = datetime.utcnow() row.updated_at = datetime.utcnow()
db.commit() db.commit()
try: try:
creds = get_device_credentials(row) creds = get_device_credentials(row)
except CredentialCryptoError as exc: 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 return
status, message, discovered = _probe_device(creds) status, message, discovered, detail = _probe_device(creds)
_update_row(ne_id, status, message, discovered) _update_row(ne_id, status, message, discovered, detail=detail)
except Exception as exc: except Exception as exc:
_log.exception("connect test failed for %s", ne_id) _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: finally:
db.close() db.close()

View file

@ -89,6 +89,7 @@ class ManagedNeOut(BaseModel):
username: str username: str
connect_status: ConnectStatus connect_status: ConnectStatus
connect_message: str connect_message: str
connect_detail: str = ""
connect_tested_at: datetime | None connect_tested_at: datetime | None
tags: str tags: str
remark: str remark: str

View file

@ -19,7 +19,7 @@ from .ne_schemas import (
ManagedNeOut, ManagedNeOut,
ManagedNeUpdate, ManagedNeUpdate,
) )
from .ne_session_factory import default_zte_hop_template from .ne_session_factory import default_hop_command_template
IMPORT_COLUMNS = ( IMPORT_COLUMNS = (
"device_type", "device_type",
@ -53,7 +53,7 @@ def _normalize_protocol(protocol: str) -> str:
def _normalize_hop_vendor(vendor: str) -> str: def _normalize_hop_vendor(vendor: str) -> str:
v = str(vendor or "zte").strip().lower() 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: def _validate_hop_on_create(body: ManagedNeCreate) -> None:
@ -123,6 +123,7 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut:
username=str(row.username or ""), username=str(row.username or ""),
connect_status=status, # type: ignore[arg-type] connect_status=status, # type: ignore[arg-type]
connect_message=str(row.connect_message or "")[:500], connect_message=str(row.connect_message or "")[:500],
connect_detail=str(row.connect_detail or "")[:8000],
connect_tested_at=row.connect_tested_at, connect_tested_at=row.connect_tested_at,
tags=str(row.tags or ""), tags=str(row.tags or ""),
remark=str(row.remark 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) hop_vendor = _normalize_hop_vendor(hop.hop_vendor)
template = str(hop.hop_command_template or "").strip() template = str(hop.hop_command_template or "").strip()
if hop_vendor == "zte" and not template: if hop_vendor != "linux" and not template:
template = default_zte_hop_template(hop.hop_protocol, hop.hop_vrf) 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()] ne_ids = [str(x).strip() for x in ids if str(x).strip()]
if not ne_ids: if not ne_ids:

View file

@ -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 from __future__ import annotations
@ -29,11 +29,49 @@ def default_zte_hop_template(protocol: str, vrf: str = "") -> str:
return f"{cmd} {{target_ip}}" 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: def render_hop_command(template: str, creds: dict[str, Any]) -> str:
"""Render hop command from template using whitelisted placeholders only.""" """Render hop command from template using whitelisted placeholders only."""
tpl = str(template or "").strip() tpl = str(template or "").strip()
if not tpl or tpl in _LEGACY_HOP_TEMPLATES: 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_protocol") or "ssh"),
str(creds.get("hop_vrf") or ""), 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") 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_host = str(creds.get("hop_host") or "").strip()
hop_user = str(creds.get("hop_username") or "").strip() hop_user = str(creds.get("hop_username") or "").strip()
hop_pass = str(creds.get("hop_password") or "") 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") raise ValueError("hop_credentials_incomplete")
hop_protocol = str(creds.get("hop_protocol") or "ssh") 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( hop_dev = _base_connect_kwargs(
device_type=hop_device_type, device_type=hop_device_type,
host=hop_host, host=hop_host,
@ -183,10 +233,6 @@ def _connect_via_zte_hop(creds: dict[str, Any], *, session_timeout: int | None =
raise 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: 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).""" """SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
hop_host = str(creds.get("hop_host") or "").strip() 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 creds.get("hop_enabled"):
if _hop_vendor(creds) == "linux": if _hop_vendor(creds) == "linux":
return _connect_via_linux_hop(creds, session_timeout=session_timeout) 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) return _connect_direct(creds, session_timeout=session_timeout)

View file

@ -32,9 +32,17 @@ class ManagedNeHostnameParseTests(unittest.TestCase):
out = "line1\nZXR10-PE1#" out = "line1\nZXR10-PE1#"
self.assertEqual(parse_hostname_from_output("zte_zxros", "ZTE", out), "ZXR10-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): def test_probe_commands(self):
self.assertIn("sysname", hostname_probe_command("huawei", "Huawei") or "") self.assertIn("sysname", hostname_probe_command("huawei", "Huawei") or "")
self.assertEqual(hostname_probe_command("zte_zxros", "ZTE"), None) 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): class ManagedNeCryptoTests(unittest.TestCase):

View file

@ -2,10 +2,10 @@ import type { ReactNode } from "react";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { import {
HOP_VENDORS, HOP_VENDORS,
defaultHopTemplate,
isAutoHopTemplate, isAutoHopTemplate,
isLinuxHopVendor, isLinuxHopVendor,
patchHopVendorChange, patchHopVendorChange,
zteHopTemplate,
type HopVendor, type HopVendor,
} from "../utils/hopProxy"; } from "../utils/hopProxy";
@ -27,7 +27,7 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({
hop_protocol: "ssh", hop_protocol: "ssh",
hop_username: "", hop_username: "",
hop_password: "", hop_password: "",
hop_command_template: zteHopTemplate("ssh", ""), hop_command_template: defaultHopTemplate("zte", "ssh", ""),
hop_vrf: "", hop_vrf: "",
}); });
@ -51,10 +51,32 @@ function applyHopTemplate(
vrf: string, vrf: string,
force = false, force = false,
): Partial<HopProxyFieldsState> { ): Partial<HopProxyFieldsState> {
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 {};
} }
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 = { type Props = {
@ -72,6 +94,7 @@ export function HopProxyFields({
}: Props) { }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const linux = isLinuxHopVendor(value.hop_vendor); const linux = isLinuxHopVendor(value.hop_vendor);
const huawei = value.hop_vendor === "huawei";
const set = (patch: Partial<HopProxyFieldsState>) => onChange(patch); const set = (patch: Partial<HopProxyFieldsState>) => onChange(patch);
@ -92,9 +115,7 @@ export function HopProxyFields({
</option> </option>
))} ))}
</select> </select>
<span className="form-field-hint"> <span className="form-field-hint">{t(hopHintKey(value.hop_vendor))}</span>
{linux ? t("managedNe.hop.linuxHint") : t("managedNe.hop.zteHint")}
</span>
</label> </label>
<label> <label>
<FormLabel required>{t("managedNe.hop.host")}</FormLabel> <FormLabel required>{t("managedNe.hop.host")}</FormLabel>
@ -118,7 +139,7 @@ export function HopProxyFields({
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) }); set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
}} }}
> >
<option value="ssh">ssh</option> <option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option>
<option value="telnet">telnet</option> <option value="telnet">telnet</option>
</select> </select>
</label> </label>
@ -144,7 +165,7 @@ export function HopProxyFields({
{!linux ? ( {!linux ? (
<> <>
<label> <label>
<FormLabel>{t("managedNe.hop.vrf")}</FormLabel> <FormLabel>{t(vrfLabelKey(value.hop_vendor))}</FormLabel>
<input <input
value={value.hop_vrf} value={value.hop_vrf}
onChange={(e) => { onChange={(e) => {
@ -158,9 +179,9 @@ export function HopProxyFields({
<input <input
value={value.hop_command_template} value={value.hop_command_template}
onChange={(e) => set({ hop_command_template: e.target.value })} onChange={(e) => set({ hop_command_template: e.target.value })}
placeholder={zteHopTemplate(value.hop_protocol, value.hop_vrf)} placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)}
/> />
<span className="form-field-hint">{t("managedNe.hop.templateHint")}</span> <span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
</label> </label>
</> </>
) : null} ) : null}

View file

@ -163,7 +163,12 @@ const en = {
run: "Connectivity test", run: "Connectivity test",
running: "Testing…", running: "Testing…",
submitted: "Submitted tests for {{n}} device(s)", 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: { importResult: {
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)", done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
}, },
@ -187,21 +192,34 @@ const en = {
enable: "Connect to target via jump host", enable: "Connect to target via jump host",
vendor: { vendor: {
zte: "ZTE device (CLI jump)", zte: "ZTE device (CLI jump)",
huawei: "Huawei device (CLI jump)",
cisco: "Cisco device (CLI jump)",
linux: "Linux server (SSH tunnel)", linux: "Linux server (SSH tunnel)",
}, },
zteHint: "Run ssh/telnet on the ZTE device to reach the target; target credentials use secondary auth.", 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).", linuxHint: "SSH to the Linux bastion, then direct-tcpip tunnel to target IP:port (ProxyJump-style).",
host: "Jump host", host: "Jump host",
port: "Jump port", port: "Jump port",
protocol: "Jump protocol", protocol: "Jump protocol",
protocolSshStelnet: "ssh (stelnet)",
username: "Jump username", username: "Jump username",
password: "Jump password", password: "Jump password",
vrf: "Mgmt VRF (optional)", vrf: "Mgmt VRF (optional)",
vpnInstance: "VPN-Instance (optional)",
vrfCisco: "VRF (optional, e.g. MGMT)",
commandTemplate: "Jump command template", commandTemplate: "Jump command template",
templateHint: 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.", "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: { badge: {
zte: "ZTE hop", zte: "ZTE hop",
huawei: "Huawei hop",
cisco: "Cisco hop",
linux: "Linux hop", linux: "Linux hop",
}, },
hostRequired: "Jump host is required", hostRequired: "Jump host is required",

View file

@ -162,7 +162,11 @@ const zh = {
run: "连通性测试", run: "连通性测试",
running: "测试中…", running: "测试中…",
submitted: "已提交 {{n}} 台设备测试", submitted: "已提交 {{n}} 台设备测试",
retest: "重新测试",
}, },
connectDetail: "详情",
connectDetailTitle: "连通性测试日志",
connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。",
importResult: { importResult: {
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行", done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
}, },
@ -186,21 +190,34 @@ const zh = {
enable: "经跳板登录目标网元", enable: "经跳板登录目标网元",
vendor: { vendor: {
zte: "ZTE 设备(CLI 跳登)", zte: "ZTE 设备(CLI 跳登)",
huawei: "华为设备(CLI 跳登)",
cisco: "思科设备(CLI 跳登)",
linux: "Linux 服务器(SSH 隧道)", linux: "Linux 服务器(SSH 隧道)",
}, },
zteHint: "在 ZTE 设备上执行 ssh/telnet 命令跳转到目标,目标账号由二次认证输入。", zteHint: "在 ZTE 设备上执行 ssh/telnet 命令跳转到目标,目标账号由二次认证输入。",
huaweiHint: "在华为设备上执行 telnet / stelnet(SSH)跳登;stelnet 即 SSH。目标账号由二次认证输入。",
ciscoHint: "在思科设备上执行 ssh -vrf / telnet /vrf 跳登,目标账号由二次认证输入。",
linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。", linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。",
host: "跳板地址", host: "跳板地址",
port: "跳板端口", port: "跳板端口",
protocol: "跳板协议", protocol: "跳板协议",
protocolSshStelnet: "ssh(stelnet)",
username: "跳板用户名", username: "跳板用户名",
password: "跳板密码", password: "跳板密码",
vrf: "管理 VRF(可选)", vrf: "管理 VRF(可选)",
vpnInstance: "VPN-Instance(可选)",
vrfCisco: "VRF(可选,如 MGMT)",
commandTemplate: "跳登命令模板", commandTemplate: "跳登命令模板",
templateHint: templateHint:
"ZTE 常用:telnet {target_ip}、telnet {target_ip} vrf {vrf}、ssh {target_ip}、ssh {target_ip} vrf {vrf}。按跳板协议与 VRF 自动推荐;留空时后端同样规则。目标账号密码由二次认证提示输入。", "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: { badge: {
zte: "ZTE跳板", zte: "ZTE跳板",
huawei: "华为跳板",
cisco: "思科跳板",
linux: "Linux跳板", linux: "Linux跳板",
}, },
hostRequired: "请填写跳板地址", hostRequired: "请填写跳板地址",

View file

@ -835,6 +835,31 @@ pre {
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.2); 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 { .modal h3 {
margin: 0 0 16px; margin: 0 0 16px;
} }

View file

@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
batchApplyHopManagedNe, batchApplyHopManagedNe,
@ -19,8 +19,12 @@ import { useToast } from "../hooks/useToast";
import type { ManagedNeItem } from "../types"; import type { ManagedNeItem } from "../types";
import { pageCount } from "../utils/display"; import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time"; import { formatSystemTime } from "../utils/time";
import { isAutoHopTemplate, patchHopVendorChange, zteHopTemplate } from "../utils/hopProxy"; import {
import type { HopVendor } from "../utils/hopProxy"; defaultHopTemplate,
isAutoHopTemplate,
patchHopVendorChange,
type HopVendor,
} from "../utils/hopProxy";
type FormState = { type FormState = {
name: string; name: string;
@ -62,15 +66,15 @@ const emptyForm = (): FormState => ({
hop_protocol: "ssh", hop_protocol: "ssh",
hop_username: "", hop_username: "",
hop_password: "", hop_password: "",
hop_command_template: zteHopTemplate("ssh", ""), hop_command_template: defaultHopTemplate("zte", "ssh", ""),
hop_vrf: "", hop_vrf: "",
}); });
function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial<FormState> { function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial<FormState> {
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 {};
} }
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 }) { function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
@ -111,6 +115,7 @@ export function NePage() {
const [editing, setEditing] = useState<ManagedNeItem | null>(null); const [editing, setEditing] = useState<ManagedNeItem | null>(null);
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields); const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
const metaQuery = useQuery({ const metaQuery = useQuery({
queryKey: queryKeys.managedNeMeta, 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 total = listQuery.data?.total ?? 0;
const pages = pageCount(total, pageSize); const pages = pageCount(total, pageSize);
const perPage = (n: number) => t("common.perPage", { n }); const perPage = (n: number) => t("common.perPage", { n });
@ -279,7 +298,9 @@ export function NePage() {
tags: row.tags, tags: row.tags,
remark: row.remark, remark: row.remark,
hop_enabled: row.hop_enabled, 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_host: row.hop_host,
hop_port: row.hop_port, hop_port: row.hop_port,
hop_protocol: row.hop_protocol, hop_protocol: row.hop_protocol,
@ -287,11 +308,12 @@ export function NePage() {
hop_password: "", hop_password: "",
hop_command_template: isAutoHopTemplate( hop_command_template: isAutoHopTemplate(
row.hop_command_template, row.hop_command_template,
row.hop_vendor,
row.hop_protocol, row.hop_protocol,
row.hop_vrf, row.hop_vrf,
) )
? zteHopTemplate(row.hop_protocol, row.hop_vrf) ? defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf)
: row.hop_command_template || zteHopTemplate(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, hop_vrf: row.hop_vrf,
}); });
setModalOpen(true); setModalOpen(true);
@ -466,7 +488,9 @@ export function NePage() {
className="table-tag" className="table-tag"
title={`${row.hop_host}:${row.hop_port} (${row.hop_vendor})`} 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"}`,
)}
</span> </span>
) : null} ) : null}
</td> </td>
@ -485,12 +509,20 @@ export function NePage() {
: t("common.empty")} : t("common.empty")}
</td> </td>
<td className="table-actions"> <td className="table-actions">
<button
type="button"
className="link-btn"
onClick={() => setConnectDetailRow(row)}
disabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail}
>
{t("managedNe.connectDetail")}
</button>
<button type="button" className="link-btn" onClick={() => openEdit(row)}> <button type="button" className="link-btn" onClick={() => openEdit(row)}>
{t("managedNe.edit")} {t("managedNe.edit")}
</button> </button>
<button <button
type="button" type="button"
className="link-btn" className="link-btn link-btn--danger"
onClick={() => { onClick={() => {
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id); if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
}} }}
@ -706,6 +738,48 @@ export function NePage() {
</div> </div>
</div> </div>
) : null} ) : null}
{connectDetailRow ? (
<div className="modal-backdrop" role="presentation" onClick={() => setConnectDetailRow(null)}>
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("managedNe.connectDetailTitle")}</h3>
<p className="form-hint">
{connectDetailRow.name || connectDetailRow.ip_address} · {connectDetailRow.ip_address}:
{connectDetailRow.port}/{connectDetailRow.protocol}
{connectDetailRow.connect_tested_at
? ` · ${formatSystemTime(connectDetailRow.connect_tested_at, { assumeUtcNaive: true })}`
: ""}
</p>
<p>
<span className={`conn-pill conn-pill--${connectPillLevel(connectDetailRow.connect_status)}`}>
{connectDetailRow.connect_status}
</span>
{connectDetailRow.connect_message ? (
<span className="connect-detail-summary"> — {connectDetailRow.connect_message}</span>
) : null}
</p>
<pre className="connect-log">
{connectDetailRow.connect_detail?.trim() ||
connectDetailRow.connect_message?.trim() ||
t("managedNe.connectDetailEmpty")}
</pre>
<div className="modal__actions">
<button
type="button"
disabled={connectMutation.isPending}
onClick={() => {
connectMutation.mutate([connectDetailRow.id]);
}}
>
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
</button>
<button type="button" onClick={() => setConnectDetailRow(null)}>
{t("managedNe.form.cancel")}
</button>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }

View file

@ -132,6 +132,7 @@ export type ManagedNeItem = {
username: string; username: string;
connect_status: ConnectStatus; connect_status: ConnectStatus;
connect_message: string; connect_message: string;
connect_detail: string;
connect_tested_at: string | null; connect_tested_at: string | null;
tags: string; tags: string;
remark: string; remark: string;

View file

@ -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 { export function isLinuxHopVendor(vendor: string): boolean {
return String(vendor || "").toLowerCase() === "linux"; 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( export function patchHopVendorChange(
vendor: HopVendor, 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 } { ): { hop_vendor: HopVendor; hop_protocol: string; hop_vrf: string; hop_command_template: string } {
if (vendor === "linux") { if (vendor === "linux") {
return { hop_vendor: "linux", hop_protocol: "ssh", hop_vrf: "", hop_command_template: "" }; return { hop_vendor: "linux", hop_protocol: "ssh", hop_vrf: "", hop_command_template: "" };
} }
const protocol = prev.hop_protocol || "ssh";
const vrf = prev.hop_vrf || "";
return { return {
hop_vendor: "zte", hop_vendor: vendor,
hop_protocol: prev.hop_protocol || "ssh", hop_protocol: protocol,
hop_vrf: prev.hop_vrf, hop_vrf: vrf,
hop_command_template: zteHopTemplate(prev.hop_protocol || "ssh", prev.hop_vrf), hop_command_template: defaultHopTemplate(vendor, protocol, vrf),
}; };
} }
export { isAutoHopTemplate, zteHopTemplate }; export { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate };

View file

@ -1,4 +1,4 @@
/** ZTE device CLI jump commands: ssh/telnet <ip> [vrf <name>]. */ /** Vendor CLI jump command templates (placeholders). */
const LEGACY_HOP_TEMPLATES = new Set([ const LEGACY_HOP_TEMPLATES = new Set([
"ssh {target_user}@{target_ip}", "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}`; 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(); const t = String(template || "").trim();
if (!t || LEGACY_HOP_TEMPLATES.has(t)) return true; 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); return t === zteHopTemplate(protocol, vrf);
} }