feat(cli): UME lazy exec via shared profiles and ume_ne_id MCP support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-26 17:13:49 +08:00
parent ba1a40f725
commit 38a1c7f3a9
21 changed files with 1504 additions and 54 deletions

219
netx_api/cli_resolve.py Normal file
View file

@ -0,0 +1,219 @@
from __future__ import annotations
import json
import re
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .device_types import SUPPORTED_DEVICE_TYPES
from .models import CliConnectProfile, ManagedNE, UmeCliOverride, UmeInventoryNE
from .ne_crypto import decrypt_secret
from .ne_service import get_device_credentials, row_to_out
_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [
(re.compile(r"ZXR|ZXCTN|M6000|\bBN\b", re.I), "zte_zxros", "ZTE"),
(re.compile(r"NE40|CE\b|ATN|MA5800|OptiX", re.I), "huawei", "Huawei"),
(re.compile(r"ASR|NCS|IOS.?XR|XR\b", re.I), "cisco_xr", "Cisco"),
(re.compile(r"Catalyst|Nexus|C9[0-9]{3}|ISR", re.I), "cisco_ios", "Cisco"),
]
def _parse_ne_type_rules(raw: str) -> list[tuple[re.Pattern[str], str, str]]:
out: list[tuple[re.Pattern[str], str, str]] = []
text = str(raw or "").strip()
if not text:
return out
try:
data = json.loads(text)
except json.JSONDecodeError:
return out
if not isinstance(data, list):
return out
for item in data:
if not isinstance(item, dict):
continue
pattern = str(item.get("pattern") or item.get("match") or "").strip()
device_type = str(item.get("device_type") or "").strip()
vendor = str(item.get("vendor") or "").strip()
if not pattern or not device_type:
continue
try:
out.append((re.compile(pattern, re.I), device_type, vendor or "Other"))
except re.error:
continue
return out
def infer_device_type_vendor(ne_type: str, profile: CliConnectProfile) -> tuple[str, str]:
text = str(ne_type or "").strip()
for rules in (_parse_ne_type_rules(profile.ne_type_rules), _BUILTIN_NE_TYPE_RULES):
for pattern, device_type, vendor in rules:
if pattern.search(text):
dt = device_type if device_type in SUPPORTED_DEVICE_TYPES else str(profile.device_type_default or "zte_zxros")
return dt, vendor or str(profile.vendor_default or "ZTE")
dt = str(profile.device_type_default or "zte_zxros").strip()
if dt not in SUPPORTED_DEVICE_TYPES:
dt = "zte_zxros"
return dt, str(profile.vendor_default or "ZTE")
def get_default_profile(db: Session) -> CliConnectProfile | None:
row = (
db.query(CliConnectProfile)
.filter(CliConnectProfile.is_default.is_(True))
.order_by(CliConnectProfile.updated_at.desc())
.first()
)
if row is not None:
return row
return db.query(CliConnectProfile).order_by(CliConnectProfile.created_at.asc()).first()
def profile_to_creds(
profile: CliConnectProfile,
*,
ip_address: str,
username: str,
device_type: str,
vendor: str,
port: int | None = None,
protocol: str | None = None,
target_password: str = "",
) -> dict[str, Any]:
hop_password = ""
if profile.hop_enabled and str(profile.hop_password_enc or "").strip():
hop_password = decrypt_secret(profile.hop_password_enc)
target_pass = target_password
if not target_pass and str(profile.password_enc or "").strip():
target_pass = decrypt_secret(profile.password_enc)
return {
"ip_address": str(ip_address),
"port": int(port or profile.port or 22),
"protocol": str(protocol or profile.protocol or "ssh"),
"username": str(username),
"password": str(target_pass),
"device_type": str(device_type),
"vendor": str(vendor),
"enable_secret": "",
"hop_enabled": bool(profile.hop_enabled),
"hop_vendor": str(profile.hop_vendor or "zte"),
"hop_host": str(profile.hop_host or ""),
"hop_port": int(profile.hop_port or 22),
"hop_protocol": str(profile.hop_protocol or "ssh"),
"hop_username": str(profile.hop_username or ""),
"hop_password": hop_password,
"hop_command_template": str(profile.hop_command_template or ""),
"hop_vrf": str(profile.hop_vrf or ""),
"hop_target_auth_mode": str(profile.hop_target_auth_mode or "bastion_managed"),
}
def resolve_cli_target(
db: Session,
*,
managed_ne_id: str | None = None,
ume_ne_id: str | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
mid = str(managed_ne_id or "").strip()
uid = str(ume_ne_id or "").strip()
if bool(mid) == bool(uid):
raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required")
if mid:
row = db.get(ManagedNE, mid)
if not row:
raise HTTPException(status_code=404, detail="managed_ne_not_found")
creds = get_device_credentials(row)
meta = row_to_out(row).model_dump()
device = {
"source": "managed",
"id": meta["id"],
"ume_ne_id": None,
"name": meta["name"],
"ip_address": meta["ip_address"],
"ne_type": "",
"host_name": meta["name"],
"vendor": meta["vendor"],
"device_type": meta["device_type"],
"port": meta["port"],
"protocol": meta["protocol"],
"connect_status": meta["connect_status"],
"hop_enabled": meta["hop_enabled"],
"hop_vendor": meta["hop_vendor"],
}
return creds, device
inv = db.get(UmeInventoryNE, uid)
if not inv:
raise HTTPException(status_code=404, detail="ume_ne_not_found")
ip = str(inv.ip_address or "").strip()
if not ip:
raise HTTPException(status_code=400, detail="ume_ne_ip_missing")
override = db.get(UmeCliOverride, uid)
profile: CliConnectProfile | None = None
if override and override.profile_id:
profile = db.get(CliConnectProfile, str(override.profile_id))
if profile is None:
profile = get_default_profile(db)
if profile is None:
raise HTTPException(status_code=503, detail="cli_connect_profile_not_configured")
username = str(override.username_override or "").strip() if override else ""
if not username:
username = str(profile.username or "").strip()
if not username:
raise HTTPException(status_code=400, detail="cli_username_required")
if override and str(override.device_type_override or "").strip():
device_type = str(override.device_type_override).strip()
vendor = str(override.vendor_override or profile.vendor_default or "ZTE").strip()
else:
device_type, vendor = infer_device_type_vendor(str(inv.ne_type or ""), profile)
if override and str(override.vendor_override or "").strip():
vendor = str(override.vendor_override).strip()
creds = profile_to_creds(
profile,
ip_address=ip,
username=username,
device_type=device_type,
vendor=vendor,
)
name = str(inv.user_label or inv.ne_name or inv.host_name or ip).strip()
connect_status = str(override.connect_status or "unknown") if override else "unknown"
device = {
"source": "ume",
"id": uid,
"ume_ne_id": uid,
"name": name,
"ip_address": ip,
"ne_type": str(inv.ne_type or ""),
"host_name": str(inv.host_name or ""),
"vendor": vendor,
"device_type": device_type,
"port": int(profile.port or 22),
"protocol": str(profile.protocol or "ssh"),
"connect_status": connect_status,
"hop_enabled": bool(profile.hop_enabled),
"hop_vendor": str(profile.hop_vendor or ""),
"cli_profile_id": str(profile.id),
"cli_profile_name": str(profile.name or ""),
}
return creds, device
def cli_profile_ready(db: Session) -> bool:
profile = get_default_profile(db)
if profile is None:
return False
if not str(profile.username or "").strip():
return False
if profile.hop_enabled:
if not str(profile.hop_host or "").strip() or not str(profile.hop_username or "").strip():
return False
if not str(profile.hop_password_enc or "").strip():
return False
return True

91
netx_api/cli_router.py Normal file
View file

@ -0,0 +1,91 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from .cli_schemas import (
CliConnectProfileCreate,
CliConnectProfileUpdate,
UmeCliOverrideUpdate,
UmeConnectTestRequest,
)
from .cli_service import (
cli_meta,
create_cli_profile,
delete_cli_profile,
get_cli_profile,
get_ume_cli_override,
list_cli_profiles,
list_cli_targets,
set_default_cli_profile,
update_cli_profile,
upsert_ume_cli_override,
)
from .db import get_db
from .ne_connect import schedule_ume_connect_tests
router = APIRouter(prefix="/v1/cli", tags=["cli"])
@router.get("/meta")
def api_cli_meta(db: Session = Depends(get_db)):
return cli_meta(db)
@router.get("/profiles")
def api_list_cli_profiles(db: Session = Depends(get_db)):
return {"items": [x.model_dump() for x in list_cli_profiles(db)]}
@router.post("/profiles")
def api_create_cli_profile(body: CliConnectProfileCreate, db: Session = Depends(get_db)):
return create_cli_profile(db, body).model_dump()
@router.get("/profiles/{profile_id}")
def api_get_cli_profile(profile_id: str, db: Session = Depends(get_db)):
return get_cli_profile(db, profile_id).model_dump()
@router.patch("/profiles/{profile_id}")
def api_update_cli_profile(profile_id: str, body: CliConnectProfileUpdate, db: Session = Depends(get_db)):
return update_cli_profile(db, profile_id, body).model_dump()
@router.post("/profiles/{profile_id}/default")
def api_set_default_cli_profile(profile_id: str, db: Session = Depends(get_db)):
return set_default_cli_profile(db, profile_id).model_dump()
@router.delete("/profiles/{profile_id}")
def api_delete_cli_profile(profile_id: str, db: Session = Depends(get_db)):
return delete_cli_profile(db, profile_id)
@router.get("/targets")
def api_list_cli_targets(
source: str = Query(default="all"),
keyword: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
db: Session = Depends(get_db),
):
return list_cli_targets(db, source=source, keyword=keyword, page=page, page_size=page_size)
@router.get("/ume-overrides/{ume_ne_id}")
def api_get_ume_cli_override(ume_ne_id: str, db: Session = Depends(get_db)):
row = get_ume_cli_override(db, ume_ne_id)
return row.model_dump() if row else None
@router.patch("/ume-overrides/{ume_ne_id}")
def api_upsert_ume_cli_override(ume_ne_id: str, body: UmeCliOverrideUpdate, db: Session = Depends(get_db)):
return upsert_ume_cli_override(db, ume_ne_id, body).model_dump()
@router.post("/ume-connect-test")
def api_ume_connect_test(body: UmeConnectTestRequest, db: Session = Depends(get_db)):
ids = [str(x).strip() for x in body.ume_ne_ids if str(x).strip()]
submitted = schedule_ume_connect_tests(ids)
return {"ok": True, "submitted": submitted}

122
netx_api/cli_schemas.py Normal file
View file

@ -0,0 +1,122 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from .device_types import SUPPORTED_VENDORS
class CliConnectProfileCreate(BaseModel):
name: str
username: str
password: str = ""
port: int = 22
protocol: str = "ssh"
device_type_default: str = "zte_zxros"
vendor_default: str = "ZTE"
ne_type_rules: str = ""
is_default: bool = False
hop_enabled: bool = False
hop_vendor: str = "zte"
hop_host: str = ""
hop_port: int = 22
hop_protocol: str = "ssh"
hop_username: str = ""
hop_password: str = ""
hop_command_template: str = ""
hop_vrf: str = ""
hop_target_auth_mode: str = "bastion_managed"
@field_validator("vendor_default")
@classmethod
def normalize_vendor(cls, v: str) -> str:
raw = str(v or "").strip()
if not raw:
return "ZTE"
for item in SUPPORTED_VENDORS:
if item.lower() == raw.lower():
return item
return "Other"
class CliConnectProfileUpdate(BaseModel):
name: str | None = None
username: str | None = None
password: str | None = None
port: int | None = None
protocol: str | None = None
device_type_default: str | None = None
vendor_default: str | None = None
ne_type_rules: str | None = None
is_default: bool | None = None
hop_enabled: bool | None = None
hop_vendor: str | None = None
hop_host: str | None = None
hop_port: int | None = None
hop_protocol: str | None = None
hop_username: str | None = None
hop_password: str | None = None
hop_command_template: str | None = None
hop_vrf: str | None = None
hop_target_auth_mode: str | None = None
class CliConnectProfileOut(BaseModel):
id: str
name: str
is_default: bool
username: str
port: int
protocol: str
device_type_default: str
vendor_default: str
ne_type_rules: str
hop_enabled: bool
hop_vendor: str
hop_host: str
hop_port: int
hop_protocol: str
hop_username: str
hop_command_template: str
hop_vrf: str
hop_target_auth_mode: str
created_at: datetime
updated_at: datetime
class UmeCliOverrideUpdate(BaseModel):
profile_id: str | None = None
username_override: str | None = None
device_type_override: str | None = None
vendor_override: str | None = None
class UmeCliOverrideOut(BaseModel):
ume_ne_id: str
profile_id: str | None
username_override: str
device_type_override: str
vendor_override: str
connect_status: str
connect_message: str
connect_detail: str
connect_tested_at: datetime | None
updated_at: datetime
class UmeConnectTestRequest(BaseModel):
ume_ne_ids: list[str] = Field(min_length=1)
class CliTargetOut(BaseModel):
source: str
id: str
ume_ne_id: str | None = None
name: str
ip_address: str
ne_type: str = ""
vendor: str = ""
device_type: str = ""
connect_status: str = "unknown"
cli_profile_ready: bool = False

332
netx_api/cli_service.py Normal file
View file

@ -0,0 +1,332 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .cli_schemas import (
CliConnectProfileCreate,
CliConnectProfileOut,
CliConnectProfileUpdate,
CliTargetOut,
UmeCliOverrideOut,
UmeCliOverrideUpdate,
)
from .cli_resolve import cli_profile_ready, get_default_profile
from .device_types import SUPPORTED_DEVICE_TYPES
from .models import CliConnectProfile, ManagedNE, UmeCliOverride, UmeInventoryNE
from .ne_crypto import credentials_configured, encrypt_secret
from .ne_service import (
_normalize_hop_target_auth_mode,
_normalize_hop_vendor,
_normalize_protocol,
_require_crypto,
list_managed_ne,
)
def _now() -> datetime:
return datetime.utcnow()
def _validate_profile_hop(body: CliConnectProfileCreate | CliConnectProfileUpdate, *, hop_enabled: bool) -> None:
if not hop_enabled:
return
host = str(getattr(body, "hop_host", None) or "").strip()
user = str(getattr(body, "hop_username", None) or "").strip()
if not host:
raise HTTPException(status_code=400, detail="hop_host_required")
if not user:
raise HTTPException(status_code=400, detail="hop_username_required")
pwd = getattr(body, "hop_password", None)
if isinstance(body, CliConnectProfileCreate) and not str(pwd or "").strip():
raise HTTPException(status_code=400, detail="hop_password_required")
def _profile_out(row: CliConnectProfile) -> CliConnectProfileOut:
return CliConnectProfileOut(
id=str(row.id),
name=str(row.name or ""),
is_default=bool(row.is_default),
username=str(row.username or ""),
port=int(row.port or 22),
protocol=str(row.protocol or "ssh"),
device_type_default=str(row.device_type_default or ""),
vendor_default=str(row.vendor_default or ""),
ne_type_rules=str(row.ne_type_rules or ""),
hop_enabled=bool(row.hop_enabled),
hop_vendor=str(row.hop_vendor or "zte"),
hop_host=str(row.hop_host or ""),
hop_port=int(row.hop_port or 22),
hop_protocol=str(row.hop_protocol or "ssh"),
hop_username=str(row.hop_username or ""),
hop_command_template=str(row.hop_command_template or ""),
hop_vrf=str(row.hop_vrf or ""),
hop_target_auth_mode=str(row.hop_target_auth_mode or "bastion_managed"),
created_at=row.created_at,
updated_at=row.updated_at,
)
def _override_out(row: UmeCliOverride) -> UmeCliOverrideOut:
return UmeCliOverrideOut(
ume_ne_id=str(row.ume_ne_id),
profile_id=str(row.profile_id) if row.profile_id else None,
username_override=str(row.username_override or ""),
device_type_override=str(row.device_type_override or ""),
vendor_override=str(row.vendor_override or ""),
connect_status=str(row.connect_status or "unknown"),
connect_message=str(row.connect_message or ""),
connect_detail=str(row.connect_detail or "")[:8000],
connect_tested_at=row.connect_tested_at,
updated_at=row.updated_at,
)
def list_cli_profiles(db: Session) -> list[CliConnectProfileOut]:
rows = db.query(CliConnectProfile).order_by(CliConnectProfile.is_default.desc(), CliConnectProfile.name.asc()).all()
return [_profile_out(r) for r in rows]
def get_cli_profile(db: Session, profile_id: str) -> CliConnectProfileOut:
row = db.get(CliConnectProfile, str(profile_id or "").strip())
if not row:
raise HTTPException(status_code=404, detail="cli_profile_not_found")
return _profile_out(row)
def create_cli_profile(db: Session, body: CliConnectProfileCreate) -> CliConnectProfileOut:
_require_crypto()
if body.device_type_default not in SUPPORTED_DEVICE_TYPES:
raise HTTPException(status_code=400, detail="unsupported_device_type")
_validate_profile_hop(body, hop_enabled=bool(body.hop_enabled))
if not str(body.username or "").strip():
raise HTTPException(status_code=400, detail="username_required")
row = CliConnectProfile(
name=str(body.name or "").strip() or "default",
username=str(body.username).strip(),
password_enc=encrypt_secret(body.password) if str(body.password or "").strip() else "",
port=int(body.port or 22),
protocol=_normalize_protocol(body.protocol),
device_type_default=str(body.device_type_default),
vendor_default=str(body.vendor_default),
ne_type_rules=str(body.ne_type_rules or ""),
hop_enabled=bool(body.hop_enabled),
hop_vendor=_normalize_hop_vendor(body.hop_vendor),
hop_host=str(body.hop_host or "").strip(),
hop_port=int(body.hop_port or 22),
hop_protocol=_normalize_protocol(body.hop_protocol),
hop_username=str(body.hop_username or "").strip(),
hop_password_enc=encrypt_secret(body.hop_password) if body.hop_enabled and body.hop_password else "",
hop_command_template=str(body.hop_command_template or "").strip(),
hop_vrf=str(body.hop_vrf or "").strip(),
hop_target_auth_mode=_normalize_hop_target_auth_mode(body.hop_target_auth_mode),
)
if body.is_default or db.query(CliConnectProfile).count() == 0:
db.query(CliConnectProfile).update({CliConnectProfile.is_default: False})
row.is_default = True
db.add(row)
db.commit()
db.refresh(row)
return _profile_out(row)
def update_cli_profile(db: Session, profile_id: str, body: CliConnectProfileUpdate) -> CliConnectProfileOut:
row = db.get(CliConnectProfile, str(profile_id or "").strip())
if not row:
raise HTTPException(status_code=404, detail="cli_profile_not_found")
data = body.model_dump(exclude_unset=True)
hop_enabled = bool(data["hop_enabled"]) if "hop_enabled" in data else bool(row.hop_enabled)
if hop_enabled:
_validate_profile_hop(body, hop_enabled=True)
if "name" in data and data["name"] is not None:
row.name = str(data["name"]).strip()
if "username" in data and data["username"] is not None:
row.username = str(data["username"]).strip()
if "password" in data and data["password"]:
_require_crypto()
row.password_enc = encrypt_secret(str(data["password"]))
if "port" in data and data["port"] is not None:
row.port = int(data["port"])
if "protocol" in data and data["protocol"] is not None:
row.protocol = _normalize_protocol(data["protocol"])
if "device_type_default" in data and data["device_type_default"] is not None:
if data["device_type_default"] not in SUPPORTED_DEVICE_TYPES:
raise HTTPException(status_code=400, detail="unsupported_device_type")
row.device_type_default = str(data["device_type_default"])
if "vendor_default" in data and data["vendor_default"] is not None:
row.vendor_default = str(data["vendor_default"])
if "ne_type_rules" in data and data["ne_type_rules"] is not None:
row.ne_type_rules = str(data["ne_type_rules"])
hop_keys = (
"hop_enabled",
"hop_vendor",
"hop_host",
"hop_port",
"hop_protocol",
"hop_username",
"hop_command_template",
"hop_vrf",
"hop_target_auth_mode",
)
for key in hop_keys:
if key in data and data[key] is not None:
setattr(row, key, data[key])
if "hop_vendor" in data and data["hop_vendor"] is not None:
row.hop_vendor = _normalize_hop_vendor(data["hop_vendor"])
if "hop_protocol" in data and data["hop_protocol"] is not None:
row.hop_protocol = _normalize_protocol(data["hop_protocol"])
if "hop_target_auth_mode" in data and data["hop_target_auth_mode"] is not None:
row.hop_target_auth_mode = _normalize_hop_target_auth_mode(data["hop_target_auth_mode"])
if "hop_password" in data and data["hop_password"]:
_require_crypto()
row.hop_password_enc = encrypt_secret(str(data["hop_password"]))
if body.is_default is True:
db.query(CliConnectProfile).filter(CliConnectProfile.id != row.id).update({CliConnectProfile.is_default: False})
row.is_default = True
row.updated_at = _now()
db.commit()
db.refresh(row)
return _profile_out(row)
def set_default_cli_profile(db: Session, profile_id: str) -> CliConnectProfileOut:
row = db.get(CliConnectProfile, str(profile_id or "").strip())
if not row:
raise HTTPException(status_code=404, detail="cli_profile_not_found")
db.query(CliConnectProfile).update({CliConnectProfile.is_default: False})
row.is_default = True
row.updated_at = _now()
db.commit()
db.refresh(row)
return _profile_out(row)
def delete_cli_profile(db: Session, profile_id: str) -> dict[str, Any]:
row = db.get(CliConnectProfile, str(profile_id or "").strip())
if not row:
raise HTTPException(status_code=404, detail="cli_profile_not_found")
was_default = bool(row.is_default)
db.delete(row)
db.commit()
if was_default:
first = db.query(CliConnectProfile).order_by(CliConnectProfile.created_at.asc()).first()
if first is not None:
first.is_default = True
first.updated_at = _now()
db.commit()
return {"ok": True}
def get_ume_cli_override(db: Session, ume_ne_id: str) -> UmeCliOverrideOut | None:
row = db.get(UmeCliOverride, str(ume_ne_id or "").strip())
return _override_out(row) if row else None
def upsert_ume_cli_override(db: Session, ume_ne_id: str, body: UmeCliOverrideUpdate) -> UmeCliOverrideOut:
uid = str(ume_ne_id or "").strip()
if not db.get(UmeInventoryNE, uid):
raise HTTPException(status_code=404, detail="ume_ne_not_found")
row = db.get(UmeCliOverride, uid)
if row is None:
row = UmeCliOverride(ume_ne_id=uid)
db.add(row)
data = body.model_dump(exclude_unset=True)
if "profile_id" in data:
pid = str(data["profile_id"] or "").strip() if data["profile_id"] else ""
if pid and not db.get(CliConnectProfile, pid):
raise HTTPException(status_code=404, detail="cli_profile_not_found")
row.profile_id = pid or None
if "username_override" in data and data["username_override"] is not None:
row.username_override = str(data["username_override"]).strip()
if "device_type_override" in data and data["device_type_override"] is not None:
row.device_type_override = str(data["device_type_override"]).strip()
if "vendor_override" in data and data["vendor_override"] is not None:
row.vendor_override = str(data["vendor_override"]).strip()
row.updated_at = _now()
db.commit()
db.refresh(row)
return _override_out(row)
def list_cli_targets(
db: Session,
*,
source: str = "all",
keyword: str | None = None,
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
src = str(source or "all").strip().lower()
if src not in ("managed", "ume", "all"):
raise HTTPException(status_code=400, detail="invalid_source")
ready = cli_profile_ready(db)
items: list[dict[str, Any]] = []
total = 0
if src in ("managed", "all"):
managed = list_managed_ne(db, keyword=keyword, page=page, page_size=page_size)
total += int(managed["total"])
for row in managed["items"]:
items.append(
CliTargetOut(
source="managed",
id=str(row.id),
ume_ne_id=None,
name=str(row.name or row.ip_address),
ip_address=str(row.ip_address),
vendor=str(row.vendor),
device_type=str(row.device_type),
connect_status=str(row.connect_status),
cli_profile_ready=True,
).model_dump()
)
if src in ("ume", "all"):
stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin(
UmeCliOverride, UmeInventoryNE.ne_id == UmeCliOverride.ume_ne_id
)
kw = str(keyword or "").strip()
if kw:
like = f"%{kw}%"
stmt = stmt.filter(
UmeInventoryNE.ne_id.ilike(like)
| UmeInventoryNE.ne_name.ilike(like)
| UmeInventoryNE.user_label.ilike(like)
| UmeInventoryNE.ip_address.ilike(like)
| UmeInventoryNE.host_name.ilike(like)
)
ume_total = stmt.count()
total += ume_total if src == "ume" else ume_total
rows = (
stmt.order_by(UmeInventoryNE.ne_id.asc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
for inv, ov in rows:
items.append(
CliTargetOut(
source="ume",
id=str(inv.ne_id),
ume_ne_id=str(inv.ne_id),
name=str(inv.user_label or inv.ne_name or inv.host_name or inv.ip_address or inv.ne_id),
ip_address=str(inv.ip_address or ""),
ne_type=str(inv.ne_type or ""),
vendor=str(inv.vendor or ""),
connect_status=str(ov.connect_status if ov else "unknown"),
cli_profile_ready=ready,
).model_dump()
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
def cli_meta(db: Session) -> dict[str, Any]:
return {
"credentials_configured": credentials_configured(),
"default_profile_configured": get_default_profile(db) is not None,
"cli_profile_ready": cli_profile_ready(db),
}

View file

@ -22,6 +22,7 @@ from .ap_client import analyze_with_oclaw, health_with_oclaw
from .config import settings from .config import settings
from .db import Base, SessionLocal, engine, get_db from .db import Base, SessionLocal, engine, get_db
from .collection_router import router as collection_router from .collection_router import router as collection_router
from .cli_router import router as cli_router
from .managed_ne_router import router as managed_ne_router from .managed_ne_router import router as managed_ne_router
from .importer import aggregate_alarms, import_alarm_excel, query_alarms from .importer import aggregate_alarms, import_alarm_excel, query_alarms
from .models import ( from .models import (
@ -120,6 +121,7 @@ from .schemas import (
app = FastAPI(title="netx ops tool", version="0.1.0") app = FastAPI(title="netx ops tool", version="0.1.0")
app.include_router(managed_ne_router) app.include_router(managed_ne_router)
app.include_router(cli_router)
app.include_router(collection_router) app.include_router(collection_router)
parser_cfg = load_parser_config() parser_cfg = load_parser_config()
_UME_CLIENT_SINGLETON = UMEClient( _UME_CLIENT_SINGLETON = UMEClient(
@ -913,6 +915,56 @@ def on_startup() -> None:
"UPDATE ne_collection_job SET last_run_at = COALESCE(ended_at, started_at, created_at) " "UPDATE ne_collection_job SET last_run_at = COALESCE(ended_at, started_at, created_at) "
"WHERE last_run_at IS NULL" "WHERE last_run_at IS NULL"
) )
conn.exec_driver_sql(
"""
CREATE TABLE IF NOT EXISTS cli_connect_profile (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(256) DEFAULT '',
is_default BOOLEAN DEFAULT FALSE,
username VARCHAR(128) DEFAULT '',
password_enc TEXT DEFAULT '',
port INTEGER DEFAULT 22,
protocol VARCHAR(16) DEFAULT 'ssh',
device_type_default VARCHAR(128) DEFAULT 'zte_zxros',
vendor_default VARCHAR(64) DEFAULT 'ZTE',
ne_type_rules TEXT DEFAULT '',
hop_enabled BOOLEAN DEFAULT FALSE,
hop_vendor VARCHAR(32) DEFAULT 'zte',
hop_host VARCHAR(128) DEFAULT '',
hop_port INTEGER DEFAULT 22,
hop_protocol VARCHAR(16) DEFAULT 'ssh',
hop_username VARCHAR(128) DEFAULT '',
hop_password_enc TEXT DEFAULT '',
hop_command_template TEXT DEFAULT '',
hop_vrf VARCHAR(128) DEFAULT '',
hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed',
created_at TIMESTAMP,
updated_at TIMESTAMP
)
"""
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_cli_connect_profile_is_default ON cli_connect_profile (is_default)"
)
conn.exec_driver_sql(
"""
CREATE TABLE IF NOT EXISTS ume_cli_override (
ume_ne_id VARCHAR(128) PRIMARY KEY,
profile_id VARCHAR(64),
username_override VARCHAR(128) DEFAULT '',
device_type_override VARCHAR(128) DEFAULT '',
vendor_override VARCHAR(64) DEFAULT '',
connect_status VARCHAR(32) DEFAULT 'unknown',
connect_message VARCHAR(512) DEFAULT '',
connect_detail TEXT DEFAULT '',
connect_tested_at TIMESTAMP,
updated_at TIMESTAMP
)
"""
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_ume_cli_override_connect_status ON ume_cli_override (connect_status)"
)
conn.exec_driver_sql("COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'") conn.exec_driver_sql("COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'")

View file

@ -118,11 +118,12 @@ def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends(
@router.post("/exec") @router.post("/exec")
def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db)): def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db)):
"""Login to a managed NE and run read-only CLI (show/display/ping). For oclaw ops tools.""" """Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping)."""
return execute_managed_ne_commands( return execute_managed_ne_commands(
db, db,
body.ne_id,
body.commands, body.commands,
ne_id=body.ne_id,
ume_ne_id=body.ume_ne_id,
read_timeout_sec=body.read_timeout_sec, read_timeout_sec=body.read_timeout_sec,
) )

View file

@ -300,6 +300,54 @@ class ManagedNE(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class CliConnectProfile(Base):
"""Reusable SSH/Telnet + hop credentials for UME lazy CLI exec."""
__tablename__ = "cli_connect_profile"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
name: Mapped[str] = mapped_column(String(256), default="", index=True)
is_default: Mapped[bool] = mapped_column(default=False, index=True)
username: Mapped[str] = mapped_column(String(128), default="")
password_enc: Mapped[str] = mapped_column(Text, default="")
port: Mapped[int] = mapped_column(Integer, default=22)
protocol: Mapped[str] = mapped_column(String(16), default="ssh")
device_type_default: Mapped[str] = mapped_column(String(128), default="zte_zxros")
vendor_default: Mapped[str] = mapped_column(String(64), default="ZTE")
ne_type_rules: Mapped[str] = mapped_column(Text, default="")
hop_enabled: Mapped[bool] = mapped_column(default=False)
hop_vendor: Mapped[str] = mapped_column(String(32), default="zte")
hop_host: Mapped[str] = mapped_column(String(128), default="")
hop_port: Mapped[int] = mapped_column(Integer, default=22)
hop_protocol: Mapped[str] = mapped_column(String(16), default="ssh")
hop_username: Mapped[str] = mapped_column(String(128), default="")
hop_password_enc: Mapped[str] = mapped_column(Text, default="")
hop_command_template: Mapped[str] = mapped_column(Text, default="")
hop_vrf: Mapped[str] = mapped_column(String(128), default="")
hop_target_auth_mode: Mapped[str] = mapped_column(String(32), default="bastion_managed")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class UmeCliOverride(Base):
"""Per-UME NE CLI overrides and connect-test cache."""
__tablename__ = "ume_cli_override"
ume_ne_id: Mapped[str] = mapped_column(String(128), primary_key=True)
profile_id: Mapped[str | None] = mapped_column(
String(64), ForeignKey("cli_connect_profile.id", ondelete="SET NULL"), nullable=True
)
username_override: Mapped[str] = mapped_column(String(128), default="")
device_type_override: Mapped[str] = mapped_column(String(128), default="")
vendor_override: Mapped[str] = mapped_column(String(64), 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)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class NeCollectionJob(Base): class NeCollectionJob(Base):
"""Batch CLI collection job over managed NEs.""" """Batch CLI collection job over managed NEs."""

View file

@ -9,8 +9,9 @@ from typing import Any
from .config import settings from .config import settings
from .db import SessionLocal from .db import SessionLocal
from .models import ManagedNE from .models import ManagedNE, UmeCliOverride, UmeInventoryNE
from .ne_crypto import CredentialCryptoError from .ne_crypto import CredentialCryptoError
from .cli_resolve import resolve_cli_target
from .ne_service import get_device_credentials from .ne_service import get_device_credentials
from .ne_session_factory import ( from .ne_session_factory import (
bastion_ssh_cli, bastion_ssh_cli,
@ -356,6 +357,75 @@ def _run_single(ne_id: str) -> None:
db.close() db.close()
def _update_ume_override_row(
ume_ne_id: str,
status: str,
message: str,
discovered_name: str | None = None,
*,
detail: str = "",
) -> None:
db = SessionLocal()
try:
uid = str(ume_ne_id or "").strip()
row = db.get(UmeCliOverride, uid)
if row is None:
if not db.get(UmeInventoryNE, uid):
return
row = UmeCliOverride(ume_ne_id=uid)
db.add(row)
row.connect_status = status
row.connect_message = str(message or "")[:500]
row.connect_detail = _truncate_detail(detail)
row.connect_tested_at = datetime.utcnow()
row.updated_at = datetime.utcnow()
db.commit()
finally:
db.close()
def _run_single_ume(ume_ne_id: str) -> None:
db = SessionLocal()
try:
uid = str(ume_ne_id or "").strip()
if not db.get(UmeInventoryNE, uid):
return
row = db.get(UmeCliOverride, uid)
if row is None:
row = UmeCliOverride(ume_ne_id=uid)
db.add(row)
row.connect_status = "testing"
row.connect_message = ""
row.connect_detail = ""
row.updated_at = datetime.utcnow()
db.commit()
try:
creds, _device = resolve_cli_target(db, ume_ne_id=uid)
except Exception as exc:
detail = _truncate_detail(traceback.format_exc())
_update_ume_override_row(uid, "fail", str(exc)[:480], detail=detail)
return
status, message, discovered, detail = _probe_device(creds)
_update_ume_override_row(uid, status, message, discovered, detail=detail)
except Exception as exc:
_log.exception("ume connect test failed for %s", ume_ne_id)
_update_ume_override_row(ume_ne_id, "fail", str(exc)[:480], detail=_truncate_detail(traceback.format_exc()))
finally:
db.close()
def schedule_ume_connect_tests(ume_ne_ids: list[str]) -> int:
pool = _executor_pool()
submitted = 0
for ume_ne_id in ume_ne_ids:
uid = str(ume_ne_id or "").strip()
if not uid:
continue
pool.submit(_run_single_ume, uid)
submitted += 1
return submitted
def schedule_connect_tests(ne_ids: list[str]) -> int: def schedule_connect_tests(ne_ids: list[str]) -> int:
pool = _executor_pool() pool = _executor_pool()
submitted = 0 submitted = 0

View file

@ -8,11 +8,10 @@ from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_resolve import resolve_cli_target
from .config import settings from .config import settings
from .models import ManagedNE
from .ne_collect_runner import _collect_on_device from .ne_collect_runner import _collect_on_device
from .ne_crypto import credentials_configured from .ne_crypto import credentials_configured
from .ne_service import get_device_credentials, row_to_out
_EXEC_MAX_COMMANDS = 5 _EXEC_MAX_COMMANDS = 5
_EXEC_MAX_OUTPUT = 32_000 _EXEC_MAX_OUTPUT = 32_000
@ -60,16 +59,18 @@ def _normalize_read_timeout(sec: int | None) -> int:
def execute_managed_ne_commands( def execute_managed_ne_commands(
db: Session, db: Session,
ne_id: str,
commands: list[str], commands: list[str],
*, *,
ne_id: str | None = None,
ume_ne_id: str | None = None,
read_timeout_sec: int | None = None, read_timeout_sec: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not credentials_configured(): if not credentials_configured():
raise HTTPException(status_code=503, detail="credential_secret_key_not_configured") raise HTTPException(status_code=503, detail="credential_secret_key_not_configured")
nid = str(ne_id or "").strip() mid = str(ne_id or "").strip()
if not nid: uid = str(ume_ne_id or "").strip()
raise HTTPException(status_code=400, detail="ne_id_required") if bool(mid) == bool(uid):
raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required")
cmds = [str(c).strip() for c in commands if str(c).strip()] cmds = [str(c).strip() for c in commands if str(c).strip()]
if not cmds: if not cmds:
raise HTTPException(status_code=400, detail="commands_required") raise HTTPException(status_code=400, detail="commands_required")
@ -78,26 +79,8 @@ def execute_managed_ne_commands(
for c in cmds: for c in cmds:
_validate_command(c) _validate_command(c)
row = db.get(ManagedNE, nid) creds, device = resolve_cli_target(db, managed_ne_id=mid or None, ume_ne_id=uid or None)
if not row:
raise HTTPException(status_code=404, detail="managed_ne_not_found")
read_timeout = _normalize_read_timeout(read_timeout_sec) read_timeout = _normalize_read_timeout(read_timeout_sec)
creds = get_device_credentials(row)
meta = row_to_out(row).model_dump()
# Shallow copy for response (no secrets).
device = {
"id": meta["id"],
"name": meta["name"],
"vendor": meta["vendor"],
"device_type": meta["device_type"],
"ip_address": meta["ip_address"],
"port": meta["port"],
"protocol": meta["protocol"],
"connect_status": meta["connect_status"],
"hop_enabled": meta["hop_enabled"],
"hop_vendor": meta["hop_vendor"],
}
prev_collect_timeout = int(settings.ne_collect_read_timeout_sec or 120) prev_collect_timeout = int(settings.ne_collect_read_timeout_sec or 120)
try: try:

View file

@ -113,9 +113,10 @@ class ConnectTestRequest(BaseModel):
class ManagedNeExecRequest(BaseModel): class ManagedNeExecRequest(BaseModel):
"""Run read-only show/display CLI on a managed NE (oclaw ops integration).""" """Run read-only show/display CLI on a managed NE or UME inventory NE (oclaw ops integration)."""
ne_id: str ne_id: str | None = None
ume_ne_id: str | None = None
commands: list[str] = Field(min_length=1, max_length=5) commands: list[str] = Field(min_length=1, max_length=5)
read_timeout_sec: int | None = Field(default=None, ge=10, le=120) read_timeout_sec: int | None = Field(default=None, ge=10, le=120)

View file

@ -221,8 +221,13 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]: def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
ne_id = str(args.get("ne_id") or "").strip() ne_id = str(args.get("ne_id") or "").strip()
if not ne_id: ume_ne_id = str(args.get("ume_ne_id") or "").strip()
return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"} if bool(ne_id) == bool(ume_ne_id):
return {
"ok": False,
"error": "exactly_one_of_ne_id_or_ume_ne_id_required",
"error_code": "exactly_one_of_ne_id_or_ume_ne_id_required",
}
raw_cmds = args.get("commands") raw_cmds = args.get("commands")
if not isinstance(raw_cmds, list) or not raw_cmds: if not isinstance(raw_cmds, list) or not raw_cmds:
return {"ok": False, "error": "commands_required", "error_code": "commands_required"} return {"ok": False, "error": "commands_required", "error_code": "commands_required"}
@ -231,7 +236,11 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
return {"ok": False, "error": "commands_required", "error_code": "commands_required"} return {"ok": False, "error": "commands_required", "error_code": "commands_required"}
if len(commands) > 5: if len(commands) > 5:
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"} return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
body: dict[str, Any] = {"ne_id": ne_id, "commands": commands} body: dict[str, Any] = {"commands": commands}
if ne_id:
body["ne_id"] = ne_id
if ume_ne_id:
body["ume_ne_id"] = ume_ne_id
rts = args.get("read_timeout_sec") rts = args.get("read_timeout_sec")
if rts is not None: if rts is not None:
body["read_timeout_sec"] = int(rts) body["read_timeout_sec"] = int(rts)
@ -244,6 +253,17 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
return {"ok": True, "data": data} return {"ok": True, "data": data}
def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
params: dict[str, Any] = {"page": page, "page_size": page_size}
if str(args.get("source") or "").strip():
params["source"] = str(args.get("source")).strip()
if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip()
return http_json("GET", "/v1/cli/targets", params=params)
HTTP_MCP_TOOLS: list[dict[str, Any]] = [ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
{ {
"name": "queryUmeAlarms", "name": "queryUmeAlarms",
@ -391,15 +411,31 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
}, },
{ {
"name": "execManagedNe", "name": "execManagedNe",
"description": "Run read-only CLI on a managed NE via netx (show/display/ping; max 5 commands).", "description": "Run read-only CLI via netx (show/display/ping; max 5 commands). Use ne_id (managed NE) OR ume_ne_id (UME inventory).",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"ne_id": {"type": "string"}, "ne_id": {"type": "string"},
"ume_ne_id": {"type": "string"},
"commands": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 5}, "commands": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 5},
"read_timeout_sec": {"type": "integer", "minimum": 10, "maximum": 120}, "read_timeout_sec": {"type": "integer", "minimum": 10, "maximum": 120},
}, },
"required": ["ne_id", "commands"], "required": ["commands"],
"additionalProperties": False,
},
},
{
"name": "listCliTargets",
"description": "List CLI-capable targets (managed NE and/or UME inventory); use before execManagedNe.",
"inputSchema": {
"type": "object",
"properties": {
"source": {"type": "string", "enum": ["managed", "ume", "all"], "default": "all"},
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False, "additionalProperties": False,
}, },
}, },
@ -418,6 +454,7 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"listManagedNe": _list_managed_ne, "listManagedNe": _list_managed_ne,
"getManagedNe": _get_managed_ne, "getManagedNe": _get_managed_ne,
"execManagedNe": _exec_managed_ne, "execManagedNe": _exec_managed_ne,
"listCliTargets": _list_cli_targets,
} }

54
tests/test_cli_resolve.py Normal file
View file

@ -0,0 +1,54 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock
from netx_api.cli_resolve import infer_device_type_vendor
from netx_api.models import CliConnectProfile, UmeInventoryNE
class CliResolveTests(unittest.TestCase):
def test_infer_zte_ne_type(self) -> None:
profile = CliConnectProfile(device_type_default="zte_zxros", vendor_default="ZTE")
dt, vendor = infer_device_type_vendor("ZXCTN 6180H", profile)
self.assertEqual(dt, "zte_zxros")
self.assertEqual(vendor, "ZTE")
def test_resolve_ume_target(self) -> None:
from netx_api.cli_resolve import resolve_cli_target
db = MagicMock()
inv = UmeInventoryNE(ne_id="ume-1", ip_address="10.0.0.1", ne_type="ZXCTN", user_label="NE-A")
profile = CliConnectProfile(
id="p1",
name="default",
is_default=True,
username="ca-oper",
password_enc="",
device_type_default="zte_zxros",
vendor_default="ZTE",
hop_enabled=True,
hop_vendor="bastion",
hop_host="10.34.145.27",
hop_username="ZTE-FIVIE",
hop_password_enc="enc",
hop_command_template="{hop_user}@{target_user}@{target_ip}",
)
db.get.side_effect = lambda model, key: {
(UmeInventoryNE, "ume-1"): inv,
(type(None), "ume-1"): None,
}.get((model, key))
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = profile
with unittest.mock.patch("netx_api.cli_resolve.decrypt_secret", return_value="vault-pass"):
creds, device = resolve_cli_target(db, ume_ne_id="ume-1")
self.assertEqual(creds["ip_address"], "10.0.0.1")
self.assertEqual(creds["username"], "ca-oper")
self.assertEqual(creds["hop_host"], "10.34.145.27")
self.assertEqual(device["source"], "ume")
self.assertEqual(device["ume_ne_id"], "ume-1")
if __name__ == "__main__":
unittest.main()

View file

@ -12,12 +12,13 @@ import pytest
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool
def test_http_mcp_tool_list_has_twelve_tools() -> None: def test_http_mcp_tool_list_has_thirteen_tools() -> None:
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS] names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
assert len(names) == 12 assert len(names) == 13
assert "queryUmeAlarms" in names assert "queryUmeAlarms" in names
assert "queryUmeAlarmsRaw" in names assert "queryUmeAlarmsRaw" in names
assert "execManagedNe" in names assert "execManagedNe" in names
assert "listCliTargets" in names
def test_call_query_ume_alarms_forwards_http() -> None: def test_call_query_ume_alarms_forwards_http() -> None:
@ -81,7 +82,7 @@ def test_stdio_initialize_and_tools_list() -> None:
list_line = proc.stdout.readline() list_line = proc.stdout.readline()
list_resp = json.loads(list_line) list_resp = json.loads(list_line)
tools = list_resp["result"]["tools"] tools = list_resp["result"]["tools"]
assert len(tools) == 12 assert len(tools) == 13
proc.terminate() proc.terminate()
proc.wait(timeout=5) proc.wait(timeout=5)

View file

@ -83,15 +83,14 @@ class NeExecValidationTests(unittest.TestCase):
class NeExecRunTests(unittest.TestCase): class NeExecRunTests(unittest.TestCase):
@patch("netx_api.ne_exec.credentials_configured", return_value=True) @patch("netx_api.ne_exec.credentials_configured", return_value=True)
@patch("netx_api.ne_exec._collect_on_device", return_value="ok-output") @patch("netx_api.ne_exec._collect_on_device", return_value="ok-output")
@patch("netx_api.ne_exec.get_device_credentials", return_value={"ip_address": "1.1.1.1"}) @patch("netx_api.ne_exec.resolve_cli_target")
def test_execute_success(self, _creds, _collect, _configured) -> None: def test_execute_success(self, resolve, _collect, _configured) -> None:
row = MagicMock() resolve.return_value = (
row.id = "ne-1" {"ip_address": "1.1.1.1"},
db = MagicMock() {
db.get.return_value = row "source": "managed",
with patch("netx_api.ne_exec.row_to_out") as row_out:
row_out.return_value.model_dump.return_value = {
"id": "ne-1", "id": "ne-1",
"ume_ne_id": None,
"name": "R2", "name": "R2",
"vendor": "Cisco", "vendor": "Cisco",
"device_type": "cisco_ios", "device_type": "cisco_ios",
@ -101,26 +100,28 @@ class NeExecRunTests(unittest.TestCase):
"connect_status": "pass", "connect_status": "pass",
"hop_enabled": False, "hop_enabled": False,
"hop_vendor": "zte", "hop_vendor": "zte",
} },
out = execute_managed_ne_commands(db, "ne-1", ["show version"]) )
db = MagicMock()
out = execute_managed_ne_commands(db, ["show version"], ne_id="ne-1")
self.assertTrue(out["ok"]) self.assertTrue(out["ok"])
self.assertEqual(out["output"], "ok-output") self.assertEqual(out["output"], "ok-output")
self.assertEqual(out["commands"], ["show version"]) self.assertEqual(out["commands"], ["show version"])
@patch("netx_api.ne_exec.credentials_configured", return_value=True) @patch("netx_api.ne_exec.credentials_configured", return_value=True)
@patch("netx_api.ne_exec._collect_on_device", return_value="ok-output") @patch("netx_api.ne_exec._collect_on_device", return_value="ok-output")
@patch("netx_api.ne_exec.get_device_credentials", return_value={"ip_address": "1.1.1.1"}) @patch("netx_api.ne_exec.resolve_cli_target")
def test_execute_skips_device_when_any_command_invalid(self, _creds, collect, _configured) -> None: def test_execute_skips_device_when_any_command_invalid(self, resolve, collect, _configured) -> None:
db = MagicMock() db = MagicMock()
with self.assertRaises(HTTPException) as ctx: with self.assertRaises(HTTPException) as ctx:
execute_managed_ne_commands( execute_managed_ne_commands(
db, db,
"ne-1",
["show interface", "configure terminal"], ["show interface", "configure terminal"],
ne_id="ne-1",
) )
self.assertEqual(ctx.exception.detail, "command_blocked") self.assertEqual(ctx.exception.detail, "command_blocked")
collect.assert_not_called() collect.assert_not_called()
db.get.assert_not_called() resolve.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
apiDelete,
apiPatch,
apiPost,
fetchCliMeta,
fetchCliProfiles,
fetchManagedNeMeta,
postUmeConnectTest,
} from "../services/api";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields";
import { HelpHint } from "./HelpHint";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import type { CliConnectProfileItem } from "../types";
type ProfileForm = {
id: string;
name: string;
username: string;
password: string;
port: number;
protocol: string;
device_type_default: string;
vendor_default: string;
is_default: boolean;
hop: HopProxyFieldsState;
};
const emptyForm = (): ProfileForm => ({
id: "",
name: "default",
username: "",
password: "",
port: 22,
protocol: "ssh",
device_type_default: "zte_zxros",
vendor_default: "ZTE",
is_default: true,
hop: { ...emptyHopProxyFields(), hop_vendor: "bastion", hop_port: 22 },
});
function profileToForm(row: CliConnectProfileItem): ProfileForm {
return {
id: row.id,
name: row.name,
username: row.username,
password: "",
port: row.port,
protocol: row.protocol,
device_type_default: row.device_type_default,
vendor_default: row.vendor_default,
is_default: row.is_default,
hop: {
hop_vendor: row.hop_vendor as HopProxyFieldsState["hop_vendor"],
hop_host: row.hop_host,
hop_port: row.hop_port,
hop_protocol: row.hop_protocol,
hop_username: row.hop_username,
hop_password: "",
hop_command_template: row.hop_command_template,
hop_vrf: row.hop_vrf,
hop_target_auth_mode: row.hop_target_auth_mode,
},
};
}
export function UmeCliConnectPanel() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [form, setForm] = useState<ProfileForm>(emptyForm);
const [sampleUmeNeId, setSampleUmeNeId] = useState("");
const metaQuery = useQuery({ queryKey: queryKeys.cliMeta, queryFn: fetchCliMeta });
const neMetaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta });
const profilesQuery = useQuery({ queryKey: queryKeys.cliProfiles, queryFn: fetchCliProfiles });
useEffect(() => {
const items = profilesQuery.data?.items || [];
const current = items.find((x) => x.id === form.id);
const picked = current || items.find((x) => x.is_default) || items[0];
if (picked && (!form.id || !current)) {
setForm(profileToForm(picked));
}
}, [profilesQuery.data, form.id]);
const saveMutation = useMutation({
mutationFn: async () => {
const body = {
name: form.name.trim(),
username: form.username.trim(),
password: form.password || undefined,
port: form.port,
protocol: form.protocol,
device_type_default: form.device_type_default,
vendor_default: form.vendor_default,
is_default: form.is_default,
hop_enabled: form.hop.hop_vendor !== "linux" ? Boolean(form.hop.hop_host.trim()) : true,
hop_vendor: form.hop.hop_vendor,
hop_host: form.hop.hop_host,
hop_port: form.hop.hop_port,
hop_protocol: form.hop.hop_protocol,
hop_username: form.hop.hop_username,
hop_password: form.hop.hop_password || undefined,
hop_command_template: form.hop.hop_command_template,
hop_vrf: form.hop.hop_vrf,
hop_target_auth_mode: form.hop.hop_target_auth_mode,
};
if (form.id) {
return apiPatch<CliConnectProfileItem>(`/v1/cli/profiles/${form.id}`, body);
}
return apiPost<CliConnectProfileItem>("/v1/cli/profiles", { ...body, password: form.password });
},
onSuccess: async (row) => {
setForm(profileToForm(row));
await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles });
await queryClient.invalidateQueries({ queryKey: queryKeys.cliMeta });
showOk(t("ume.cli.saved"));
},
onError: (e: Error) => showError(e.message),
});
const connectTestMutation = useMutation({
mutationFn: async () => {
const id = sampleUmeNeId.trim();
if (!id) throw new Error(t("ume.cli.sampleNeRequired"));
return postUmeConnectTest([id]);
},
onSuccess: () => {
showOk(t("ume.cli.connectTestSubmitted"));
},
onError: (e: Error) => showError(e.message),
});
const deviceTypes = neMetaQuery.data?.device_types ?? [];
const vendors = neMetaQuery.data?.vendors ?? [];
const cliReady = Boolean(metaQuery.data?.cli_profile_ready);
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("ume.cli.title")}</h2>
<HelpHint text={t("ume.cli.hint")} />
</div>
<p className="form-field-hint" style={{ marginBottom: 12 }}>
{cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")}
</p>
<div className="form-grid" style={{ maxWidth: 960 }}>
<label>
<span className="form-label">{t("ume.cli.profileName")}</span>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label>
<label>
<span className="form-label">{t("ume.cli.targetUsername")}</span>
<input
required
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
placeholder="ca-oper"
/>
</label>
<label>
<span className="form-label">{t("ume.cli.targetPassword")}</span>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
placeholder={form.id ? t("ume.cli.passwordKeep") : ""}
/>
</label>
<label>
<span className="form-label">{t("managedNe.form.deviceType")}</span>
<select
value={form.device_type_default}
onChange={(e) => setForm({ ...form, device_type_default: e.target.value })}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</select>
</label>
<label>
<span className="form-label">{t("managedNe.form.vendor")}</span>
<select value={form.vendor_default} onChange={(e) => setForm({ ...form, vendor_default: e.target.value })}>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
</div>
<HopProxyFields
value={form.hop}
onChange={(hop) => setForm({ ...form, hop })}
hopPasswordRequired={!form.id}
hopPasswordOptional={Boolean(form.id)}
/>
<div className="filter-inline" style={{ marginTop: 16 }}>
<button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button>
<button
type="button"
onClick={() => {
setForm(emptyForm());
}}
>
{t("ume.cli.newProfile")}
</button>
{form.id ? (
<button
type="button"
className="danger-btn"
onClick={async () => {
if (!window.confirm(t("ume.cli.deleteConfirm"))) return;
try {
await apiDelete(`/v1/cli/profiles/${form.id}`);
setForm(emptyForm());
await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles });
showOk(t("ume.cli.deleted"));
} catch (e) {
showError(e instanceof Error ? e.message : String(e));
}
}}
>
{t("managedNe.delete")}
</button>
) : null}
</div>
<div className="panel" style={{ marginTop: 20 }}>
<h3>{t("ume.cli.connectTestTitle")}</h3>
<div className="filter-inline">
<input
value={sampleUmeNeId}
onChange={(e) => setSampleUmeNeId(e.target.value)}
placeholder={t("ume.cli.sampleNePh")}
style={{ minWidth: 320 }}
/>
<button type="button" onClick={() => connectTestMutation.mutate()} disabled={connectTestMutation.isPending}>
{t("managedNe.connect.run")}
</button>
</div>
</div>
{(profilesQuery.data?.items || []).length > 1 ? (
<div style={{ marginTop: 16 }}>
<span className="form-label">{t("ume.cli.existingProfiles")}</span>
<div className="filter-inline">
{(profilesQuery.data?.items || []).map((p) => (
<button key={p.id} type="button" className="link-btn" onClick={() => setForm(profileToForm(p))}>
{p.name}
{p.is_default ? ` (${t("ume.cli.default")})` : ""}
</button>
))}
</div>
</div>
) : null}
</section>
);
}

View file

@ -32,6 +32,10 @@ export const queryKeys = {
neCollectionRunsAll: ["neCollectionRuns"] as const, neCollectionRunsAll: ["neCollectionRuns"] as const,
neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) => neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) =>
["neCollectionRuns", jobId, page, status, keyword] as const, ["neCollectionRuns", jobId, page, status, keyword] as const,
cliMeta: ["cliMeta"] as const,
cliProfiles: ["cliProfiles"] as const,
cliTargets: (keyword: string, page: number, pageSize: number) =>
["cliTargets", keyword, page, pageSize] as const,
umeCurrentAlarms: ( umeCurrentAlarms: (
severity: string, severity: string,
cleared: string, cleared: string,

View file

@ -465,6 +465,31 @@ const en = {
clearTitle: "Clear keyword and reset to page 1", clearTitle: "Clear keyword and reset to page 1",
expand: "Expand details", expand: "Expand details",
collapse: "Collapse details", collapse: "Collapse details",
cliConnect: "CLI connect",
},
tabs: {
main: "UME sync",
cli: "CLI connect",
},
cli: {
title: "UME CLI connection profile",
hint: "Unified SSH credentials and hop/bastion for UME inventory NEs. OClaw MCP can exec show commands via ume_ne_id without per-device managed_ne entries.",
statusReady: "CLI profile is ready for UME NE command execution.",
statusNotReady: "Set target username and hop/bastion settings (if enabled).",
profileName: "Profile name",
targetUsername: "Target SSH username",
targetPassword: "Target SSH password",
passwordKeep: "Leave blank to keep unchanged",
newProfile: "New profile",
deleteConfirm: "Delete this CLI profile?",
deleted: "Profile deleted",
saved: "CLI profile saved",
connectTestTitle: "Sample NE connectivity test",
sampleNePh: "ume_ne_id (copy from NE list below)",
sampleNeRequired: "ume_ne_id is required",
connectTestSubmitted: "Connectivity test submitted",
existingProfiles: "Existing profiles",
default: "default",
}, },
alarms: { alarms: {
title: "Current alarms", title: "Current alarms",

View file

@ -463,6 +463,31 @@ const zh = {
clearTitle: "清空 keyword,回到第 1 页", clearTitle: "清空 keyword,回到第 1 页",
expand: "展开详情", expand: "展开详情",
collapse: "收起详情", collapse: "收起详情",
cliConnect: "CLI 连通",
},
tabs: {
main: "UME 同步",
cli: "CLI 连接",
},
cli: {
title: "UME CLI 连接配置",
hint: "为 UME 清单网元配置统一 SSH 凭据与跳板/堡垒机;OClaw MCP 可通过 ume_ne_id 直接执行 show 命令,无需逐台录入 managed_ne。",
statusReady: "CLI 配置已就绪,可对 UME 网元执行命令。",
statusNotReady: "请填写目标用户名,并配置跳板/堡垒机(若启用)。",
profileName: "配置名称",
targetUsername: "目标 SSH 用户名",
targetPassword: "目标 SSH 密码",
passwordKeep: "留空则不修改",
newProfile: "新建配置",
deleteConfirm: "确定删除该 CLI 配置?",
deleted: "配置已删除",
saved: "CLI 配置已保存",
connectTestTitle: "样本网元连通性测试",
sampleNePh: "ume_ne_id(从下方网元清单复制)",
sampleNeRequired: "请填写 ume_ne_id",
connectTestSubmitted: "已提交连通性测试",
existingProfiles: "已有配置",
default: "默认",
}, },
alarms: { alarms: {
title: "当前告警", title: "当前告警",

View file

@ -1,4 +1,4 @@
import { Fragment, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
apiPost, apiPost,
@ -15,6 +15,7 @@ import {
fetchUmeNotificationIds, fetchUmeNotificationIds,
fetchUmeSyncStatus, fetchUmeSyncStatus,
fetchUmeTokenStatus, fetchUmeTokenStatus,
fetchCliTargets,
refreshUmeToken, refreshUmeToken,
upsertUmeKeyAlertRule, upsertUmeKeyAlertRule,
deleteUmeKeyAlertRule, deleteUmeKeyAlertRule,
@ -22,6 +23,7 @@ import {
updateUmeKeyAlertMonitorConfig, updateUmeKeyAlertMonitorConfig,
} from "../services/api"; } from "../services/api";
import { HelpHint } from "../components/HelpHint"; import { HelpHint } from "../components/HelpHint";
import { UmeCliConnectPanel } from "../components/UmeCliConnectPanel";
import { queryKeys } from "../constants/queryKeys"; import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; import { useToast } from "../hooks/useToast";
@ -67,6 +69,7 @@ export function UmePage() {
const [keyAlertNeTypes, setKeyAlertNeTypes] = useState<string[]>([]); const [keyAlertNeTypes, setKeyAlertNeTypes] = useState<string[]>([]);
const [keyAlertEditRule, setKeyAlertEditRule] = useState<UmeKeyAlertRuleItem | null>(null); const [keyAlertEditRule, setKeyAlertEditRule] = useState<UmeKeyAlertRuleItem | null>(null);
const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState<string[]>([]); const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState<string[]>([]);
const [umeViewTab, setUmeViewTab] = useState<"main" | "cli">("main");
const syncMutation = useMutation({ const syncMutation = useMutation({
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }), mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
@ -254,6 +257,20 @@ export function UmePage() {
queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }), queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }),
staleTime: 5000, staleTime: 5000,
}); });
const cliTargetsQuery = useQuery({
queryKey: queryKeys.cliTargets(neKeyword, nePage, nePageSize),
queryFn: () =>
fetchCliTargets({ source: "ume", keyword: neKeyword, page: nePage, pageSize: nePageSize }),
enabled: nePanelOpen && umeViewTab === "main",
staleTime: 5000,
});
const cliStatusByNeId = useMemo(() => {
const map = new Map<string, string>();
for (const row of cliTargetsQuery.data?.items || []) {
map.set(row.id, row.connect_status);
}
return map;
}, [cliTargetsQuery.data]);
const currentQuery = useQuery({ const currentQuery = useQuery({
queryKey: queryKeys.umeCurrentAlarms(curSeverity, curCleared, curHostName, curKeyword, curPage, curPageSize), queryKey: queryKeys.umeCurrentAlarms(curSeverity, curCleared, curHostName, curKeyword, curPage, curPageSize),
queryFn: () => queryFn: () =>
@ -442,6 +459,30 @@ export function UmePage() {
return ( return (
<> <>
<section className="panel" style={{ marginBottom: 12 }}>
<div className="filter-inline">
<button
type="button"
className={umeViewTab === "main" ? "link-btn" : "link-btn"}
style={{ fontWeight: umeViewTab === "main" ? 700 : 400 }}
onClick={() => setUmeViewTab("main")}
>
{t("ume.tabs.main")}
</button>
<button
type="button"
className="link-btn"
style={{ fontWeight: umeViewTab === "cli" ? 700 : 400 }}
onClick={() => setUmeViewTab("cli")}
>
{t("ume.tabs.cli")}
</button>
</div>
</section>
{umeViewTab === "cli" ? (
<UmeCliConnectPanel />
) : (
<>
<section className="cards"> <section className="cards">
<article className="card card--full"> <article className="card card--full">
<h3>{t("ume.token.title")}</h3> <h3>{t("ume.token.title")}</h3>
@ -1245,6 +1286,7 @@ export function UmePage() {
<th>type</th> <th>type</th>
<th>device_level</th> <th>device_level</th>
<th>host_name</th> <th>host_name</th>
<th>{t("ume.ne.cliConnect")}</th>
<th>hw_ver</th> <th>hw_ver</th>
<th>last_seen</th> <th>last_seen</th>
</tr> </tr>
@ -1267,12 +1309,13 @@ export function UmePage() {
<td>{x.ne_type}</td> <td>{x.ne_type}</td>
<td>{x.device_level || t("common.empty")}</td> <td>{x.device_level || t("common.empty")}</td>
<td>{x.host_name || t("common.empty")}</td> <td>{x.host_name || t("common.empty")}</td>
<td>{cliStatusByNeId.get(x.ne_id) || "unknown"}</td>
<td>{x.hardware_version || t("common.empty")}</td> <td>{x.hardware_version || t("common.empty")}</td>
<td>{x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")}</td> <td>{x.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")}</td>
</tr> </tr>
{expandedNeId === x.ne_id ? ( {expandedNeId === x.ne_id ? (
<tr> <tr>
<td colSpan={8}> <td colSpan={9}>
<div style={{ fontSize: 12, display: "grid", gridTemplateColumns: "repeat(3, minmax(180px, 1fr))", gap: 8 }}> <div style={{ fontSize: 12, display: "grid", gridTemplateColumns: "repeat(3, minmax(180px, 1fr))", gap: 8 }}>
<div>consistent_state: {x.consistent_state || t("common.empty")}</div> <div>consistent_state: {x.consistent_state || t("common.empty")}</div>
<div>admin_status: {x.admin_status || t("common.empty")}</div> <div>admin_status: {x.admin_status || t("common.empty")}</div>
@ -1511,5 +1554,7 @@ export function UmePage() {
</div> </div>
) : null} ) : null}
</> </>
)}
</>
); );
} }

View file

@ -14,6 +14,9 @@ import type {
UmeNeItem, UmeNeItem,
UmeSyncStatusResponse, UmeSyncStatusResponse,
UmeTokenStatus, UmeTokenStatus,
CliConnectProfileItem,
CliMeta,
CliTargetListResponse,
} from "../types"; } from "../types";
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => { const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
@ -306,6 +309,28 @@ export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/r
export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`; export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`;
export const fetchCliMeta = () => apiGet<CliMeta>("/v1/cli/meta");
export const fetchCliProfiles = () =>
apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles");
export const fetchCliTargets = (params: {
source?: "managed" | "ume" | "all";
keyword?: string;
page?: number;
pageSize?: number;
}) => {
const p = new URLSearchParams();
p.set("source", params.source || "all");
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
return apiGet<CliTargetListResponse>(`/v1/cli/targets?${p.toString()}`);
};
export const postUmeConnectTest = (umeNeIds: string[]) =>
apiPost<{ ok: boolean; submitted: number }>("/v1/cli/ume-connect-test", { ume_ne_ids: umeNeIds });
export const fetchUmeCurrentAlarms = (params: { export const fetchUmeCurrentAlarms = (params: {
severity: string; severity: string;
isCleared: string; isCleared: string;

View file

@ -219,6 +219,55 @@ export type ManagedNeImportResult = {
failed: Array<{ row: number; reason: string }>; failed: Array<{ row: number; reason: string }>;
}; };
export type CliConnectProfileItem = {
id: string;
name: string;
is_default: boolean;
username: string;
port: number;
protocol: string;
device_type_default: string;
vendor_default: string;
ne_type_rules: string;
hop_enabled: boolean;
hop_vendor: string;
hop_host: string;
hop_port: number;
hop_protocol: string;
hop_username: string;
hop_command_template: string;
hop_vrf: string;
hop_target_auth_mode: string;
created_at: string;
updated_at: string;
};
export type CliMeta = {
credentials_configured: boolean;
default_profile_configured: boolean;
cli_profile_ready: boolean;
};
export type CliTargetItem = {
source: string;
id: string;
ume_ne_id?: string | null;
name: string;
ip_address: string;
ne_type?: string;
vendor?: string;
device_type?: string;
connect_status: string;
cli_profile_ready?: boolean;
};
export type CliTargetListResponse = {
total: number;
page: number;
page_size: number;
items: CliTargetItem[];
};
export type EligibleNeItem = { export type EligibleNeItem = {
id: string; id: string;
name: string; name: string;