mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
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:
parent
ba1a40f725
commit
38a1c7f3a9
21 changed files with 1504 additions and 54 deletions
219
netx_api/cli_resolve.py
Normal file
219
netx_api/cli_resolve.py
Normal 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
91
netx_api/cli_router.py
Normal 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
122
netx_api/cli_schemas.py
Normal 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
332
netx_api/cli_service.py
Normal 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),
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ from .ap_client import analyze_with_oclaw, health_with_oclaw
|
|||
from .config import settings
|
||||
from .db import Base, SessionLocal, engine, get_db
|
||||
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 .importer import aggregate_alarms, import_alarm_excel, query_alarms
|
||||
from .models import (
|
||||
|
|
@ -120,6 +121,7 @@ from .schemas import (
|
|||
|
||||
app = FastAPI(title="netx ops tool", version="0.1.0")
|
||||
app.include_router(managed_ne_router)
|
||||
app.include_router(cli_router)
|
||||
app.include_router(collection_router)
|
||||
parser_cfg = load_parser_config()
|
||||
_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) "
|
||||
"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 COLUMN ume_inventory_ne.ne_id IS '网元uuid'")
|
||||
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'")
|
||||
|
|
|
|||
|
|
@ -118,11 +118,12 @@ def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends(
|
|||
|
||||
@router.post("/exec")
|
||||
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(
|
||||
db,
|
||||
body.ne_id,
|
||||
body.commands,
|
||||
ne_id=body.ne_id,
|
||||
ume_ne_id=body.ume_ne_id,
|
||||
read_timeout_sec=body.read_timeout_sec,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -300,6 +300,54 @@ class ManagedNE(Base):
|
|||
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):
|
||||
"""Batch CLI collection job over managed NEs."""
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ from typing import Any
|
|||
|
||||
from .config import settings
|
||||
from .db import SessionLocal
|
||||
from .models import ManagedNE
|
||||
from .models import ManagedNE, UmeCliOverride, UmeInventoryNE
|
||||
from .ne_crypto import CredentialCryptoError
|
||||
from .cli_resolve import resolve_cli_target
|
||||
from .ne_service import get_device_credentials
|
||||
from .ne_session_factory import (
|
||||
bastion_ssh_cli,
|
||||
|
|
@ -356,6 +357,75 @@ def _run_single(ne_id: str) -> None:
|
|||
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:
|
||||
pool = _executor_pool()
|
||||
submitted = 0
|
||||
|
|
|
|||
|
|
@ -8,11 +8,10 @@ from typing import Any
|
|||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .cli_resolve import resolve_cli_target
|
||||
from .config import settings
|
||||
from .models import ManagedNE
|
||||
from .ne_collect_runner import _collect_on_device
|
||||
from .ne_crypto import credentials_configured
|
||||
from .ne_service import get_device_credentials, row_to_out
|
||||
|
||||
_EXEC_MAX_COMMANDS = 5
|
||||
_EXEC_MAX_OUTPUT = 32_000
|
||||
|
|
@ -60,16 +59,18 @@ def _normalize_read_timeout(sec: int | None) -> int:
|
|||
|
||||
def execute_managed_ne_commands(
|
||||
db: Session,
|
||||
ne_id: str,
|
||||
commands: list[str],
|
||||
*,
|
||||
ne_id: str | None = None,
|
||||
ume_ne_id: str | None = None,
|
||||
read_timeout_sec: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not credentials_configured():
|
||||
raise HTTPException(status_code=503, detail="credential_secret_key_not_configured")
|
||||
nid = str(ne_id or "").strip()
|
||||
if not nid:
|
||||
raise HTTPException(status_code=400, detail="ne_id_required")
|
||||
mid = str(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")
|
||||
cmds = [str(c).strip() for c in commands if str(c).strip()]
|
||||
if not cmds:
|
||||
raise HTTPException(status_code=400, detail="commands_required")
|
||||
|
|
@ -78,26 +79,8 @@ def execute_managed_ne_commands(
|
|||
for c in cmds:
|
||||
_validate_command(c)
|
||||
|
||||
row = db.get(ManagedNE, nid)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="managed_ne_not_found")
|
||||
|
||||
creds, device = resolve_cli_target(db, managed_ne_id=mid or None, ume_ne_id=uid or None)
|
||||
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)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -113,9 +113,10 @@ class ConnectTestRequest(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)
|
||||
read_timeout_sec: int | None = Field(default=None, ge=10, le=120)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
if not ne_id:
|
||||
return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"}
|
||||
ume_ne_id = str(args.get("ume_ne_id") or "").strip()
|
||||
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")
|
||||
if not isinstance(raw_cmds, list) or not raw_cmds:
|
||||
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"}
|
||||
if len(commands) > 5:
|
||||
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")
|
||||
if rts is not None:
|
||||
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}
|
||||
|
||||
|
||||
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]] = [
|
||||
{
|
||||
"name": "queryUmeAlarms",
|
||||
|
|
@ -391,15 +411,31 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
{
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ne_id": {"type": "string"},
|
||||
"ume_ne_id": {"type": "string"},
|
||||
"commands": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 5},
|
||||
"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,
|
||||
},
|
||||
},
|
||||
|
|
@ -418,6 +454,7 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
|||
"listManagedNe": _list_managed_ne,
|
||||
"getManagedNe": _get_managed_ne,
|
||||
"execManagedNe": _exec_managed_ne,
|
||||
"listCliTargets": _list_cli_targets,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
54
tests/test_cli_resolve.py
Normal file
54
tests/test_cli_resolve.py
Normal 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()
|
||||
|
|
@ -12,12 +12,13 @@ import pytest
|
|||
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]
|
||||
assert len(names) == 12
|
||||
assert len(names) == 13
|
||||
assert "queryUmeAlarms" in names
|
||||
assert "queryUmeAlarmsRaw" in names
|
||||
assert "execManagedNe" in names
|
||||
assert "listCliTargets" in names
|
||||
|
||||
|
||||
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_resp = json.loads(list_line)
|
||||
tools = list_resp["result"]["tools"]
|
||||
assert len(tools) == 12
|
||||
assert len(tools) == 13
|
||||
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
|
|
|||
|
|
@ -83,15 +83,14 @@ class NeExecValidationTests(unittest.TestCase):
|
|||
class NeExecRunTests(unittest.TestCase):
|
||||
@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.get_device_credentials", return_value={"ip_address": "1.1.1.1"})
|
||||
def test_execute_success(self, _creds, _collect, _configured) -> None:
|
||||
row = MagicMock()
|
||||
row.id = "ne-1"
|
||||
db = MagicMock()
|
||||
db.get.return_value = row
|
||||
with patch("netx_api.ne_exec.row_to_out") as row_out:
|
||||
row_out.return_value.model_dump.return_value = {
|
||||
@patch("netx_api.ne_exec.resolve_cli_target")
|
||||
def test_execute_success(self, resolve, _collect, _configured) -> None:
|
||||
resolve.return_value = (
|
||||
{"ip_address": "1.1.1.1"},
|
||||
{
|
||||
"source": "managed",
|
||||
"id": "ne-1",
|
||||
"ume_ne_id": None,
|
||||
"name": "R2",
|
||||
"vendor": "Cisco",
|
||||
"device_type": "cisco_ios",
|
||||
|
|
@ -101,26 +100,28 @@ class NeExecRunTests(unittest.TestCase):
|
|||
"connect_status": "pass",
|
||||
"hop_enabled": False,
|
||||
"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.assertEqual(out["output"], "ok-output")
|
||||
self.assertEqual(out["commands"], ["show version"])
|
||||
|
||||
@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.get_device_credentials", return_value={"ip_address": "1.1.1.1"})
|
||||
def test_execute_skips_device_when_any_command_invalid(self, _creds, collect, _configured) -> None:
|
||||
@patch("netx_api.ne_exec.resolve_cli_target")
|
||||
def test_execute_skips_device_when_any_command_invalid(self, resolve, collect, _configured) -> None:
|
||||
db = MagicMock()
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
execute_managed_ne_commands(
|
||||
db,
|
||||
"ne-1",
|
||||
["show interface", "configure terminal"],
|
||||
ne_id="ne-1",
|
||||
)
|
||||
self.assertEqual(ctx.exception.detail, "command_blocked")
|
||||
collect.assert_not_called()
|
||||
db.get.assert_not_called()
|
||||
resolve.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
265
web/src/components/UmeCliConnectPanel.tsx
Normal file
265
web/src/components/UmeCliConnectPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,6 +32,10 @@ export const queryKeys = {
|
|||
neCollectionRunsAll: ["neCollectionRuns"] as const,
|
||||
neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) =>
|
||||
["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: (
|
||||
severity: string,
|
||||
cleared: string,
|
||||
|
|
|
|||
|
|
@ -465,6 +465,31 @@ const en = {
|
|||
clearTitle: "Clear keyword and reset to page 1",
|
||||
expand: "Expand 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: {
|
||||
title: "Current alarms",
|
||||
|
|
|
|||
|
|
@ -463,6 +463,31 @@ const zh = {
|
|||
clearTitle: "清空 keyword,回到第 1 页",
|
||||
expand: "展开详情",
|
||||
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: {
|
||||
title: "当前告警",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, useState } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
apiPost,
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
fetchUmeNotificationIds,
|
||||
fetchUmeSyncStatus,
|
||||
fetchUmeTokenStatus,
|
||||
fetchCliTargets,
|
||||
refreshUmeToken,
|
||||
upsertUmeKeyAlertRule,
|
||||
deleteUmeKeyAlertRule,
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
updateUmeKeyAlertMonitorConfig,
|
||||
} from "../services/api";
|
||||
import { HelpHint } from "../components/HelpHint";
|
||||
import { UmeCliConnectPanel } from "../components/UmeCliConnectPanel";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
|
|
@ -67,6 +69,7 @@ export function UmePage() {
|
|||
const [keyAlertNeTypes, setKeyAlertNeTypes] = useState<string[]>([]);
|
||||
const [keyAlertEditRule, setKeyAlertEditRule] = useState<UmeKeyAlertRuleItem | null>(null);
|
||||
const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState<string[]>([]);
|
||||
const [umeViewTab, setUmeViewTab] = useState<"main" | "cli">("main");
|
||||
|
||||
const syncMutation = useMutation({
|
||||
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 }),
|
||||
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({
|
||||
queryKey: queryKeys.umeCurrentAlarms(curSeverity, curCleared, curHostName, curKeyword, curPage, curPageSize),
|
||||
queryFn: () =>
|
||||
|
|
@ -442,6 +459,30 @@ export function UmePage() {
|
|||
|
||||
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">
|
||||
<article className="card card--full">
|
||||
<h3>{t("ume.token.title")}</h3>
|
||||
|
|
@ -1245,6 +1286,7 @@ export function UmePage() {
|
|||
<th>type</th>
|
||||
<th>device_level</th>
|
||||
<th>host_name</th>
|
||||
<th>{t("ume.ne.cliConnect")}</th>
|
||||
<th>hw_ver</th>
|
||||
<th>last_seen</th>
|
||||
</tr>
|
||||
|
|
@ -1267,12 +1309,13 @@ export function UmePage() {
|
|||
<td>{x.ne_type}</td>
|
||||
<td>{x.device_level || 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.last_seen_at ? formatSystemTime(x.last_seen_at) : t("common.empty")}</td>
|
||||
</tr>
|
||||
{expandedNeId === x.ne_id ? (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<td colSpan={9}>
|
||||
<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>admin_status: {x.admin_status || t("common.empty")}</div>
|
||||
|
|
@ -1511,5 +1554,7 @@ export function UmePage() {
|
|||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import type {
|
|||
UmeNeItem,
|
||||
UmeSyncStatusResponse,
|
||||
UmeTokenStatus,
|
||||
CliConnectProfileItem,
|
||||
CliMeta,
|
||||
CliTargetListResponse,
|
||||
} from "../types";
|
||||
|
||||
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 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: {
|
||||
severity: string;
|
||||
isCleared: string;
|
||||
|
|
|
|||
|
|
@ -219,6 +219,55 @@ export type ManagedNeImportResult = {
|
|||
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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue