diff --git a/netx_api/main.py b/netx_api/main.py index 813731d..72da24e 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -907,6 +907,8 @@ def on_startup() -> None: conn.exec_driver_sql( "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed'" ) + conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source VARCHAR(64) DEFAULT ''") + conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source_ref VARCHAR(128) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''") conn.exec_driver_sql( "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP" diff --git a/netx_api/managed_ne_router.py b/netx_api/managed_ne_router.py index ee10a25..a1b5629 100644 --- a/netx_api/managed_ne_router.py +++ b/netx_api/managed_ne_router.py @@ -9,18 +9,28 @@ from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS from .ne_connect import schedule_connect_tests from .ne_crypto import credentials_configured from .ne_exec import execute_managed_ne_commands -from .ne_schemas import BatchHopApplyRequest, ConnectTestRequest, ManagedNeCreate, ManagedNeExecRequest, ManagedNeUpdate +from .ne_schemas import ( + BatchAccountApplyRequest, + BatchHopApplyRequest, + ConnectTestRequest, + ManagedNeCreate, + ManagedNeExecRequest, + ManagedNeUpdate, +) from .ne_service import ( + batch_apply_account, batch_apply_hop_proxy, build_managed_ne_import_template, create_managed_ne, batch_delete_managed_ne, + delete_ume_synced_managed_ne, delete_managed_ne, get_ids_by_tag, get_managed_ne, get_managed_ne_stats, import_managed_ne, list_managed_ne, + sync_ume_inventory_to_managed_ne, update_managed_ne, ) from .models import ManagedNE @@ -111,11 +121,26 @@ def api_batch_apply_hop(body: BatchHopApplyRequest, db: Session = Depends(get_db return batch_apply_hop_proxy(db, body.ids, body.hop) +@router.post("/batch-account") +def api_batch_apply_account(body: BatchAccountApplyRequest, db: Session = Depends(get_db)): + return batch_apply_account(db, body.ids, body.account) + + @router.post("/batch-delete") def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends(get_db)): return batch_delete_managed_ne(db, body.ids) +@router.post("/ume-sync") +def api_sync_ume_inventory_to_managed_ne(db: Session = Depends(get_db)): + return sync_ume_inventory_to_managed_ne(db).model_dump() + + +@router.delete("/ume-sync") +def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)): + return delete_ume_synced_managed_ne(db).model_dump() + + @router.post("/exec") def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db)): """Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping).""" diff --git a/netx_api/models.py b/netx_api/models.py index eedf56b..aea6c03 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -286,6 +286,8 @@ class ManagedNE(Base): site: Mapped[str] = mapped_column(String(256), default="") tags: Mapped[str] = mapped_column(String(512), default="") remark: Mapped[str] = mapped_column(String(1024), default="") + source: Mapped[str] = mapped_column(String(64), default="", index=True) + source_ref: Mapped[str] = mapped_column(String(128), default="", index=True) 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="") diff --git a/netx_api/ne_schemas.py b/netx_api/ne_schemas.py index a5e2342..f9efa76 100644 --- a/netx_api/ne_schemas.py +++ b/netx_api/ne_schemas.py @@ -129,7 +129,7 @@ class HopProxyConfig(BaseModel): hop_port: int = 22 hop_protocol: str = "ssh" hop_username: str - hop_password: str + hop_password: str = "" hop_command_template: str = "" hop_vrf: str = "" hop_target_auth_mode: str = "bastion_managed" @@ -140,6 +140,16 @@ class BatchHopApplyRequest(BaseModel): hop: HopProxyConfig +class BatchAccountConfig(BaseModel): + username: str = "" + password: str = "" + + +class BatchAccountApplyRequest(BaseModel): + ids: list[str] = Field(min_length=1) + account: BatchAccountConfig + + class ImportFailure(BaseModel): row: int reason: str @@ -149,3 +159,14 @@ class ImportResult(BaseModel): inserted: int updated: int failed: list[ImportFailure] + + +class UmeManagedSyncResult(BaseModel): + inserted: int + updated: int + deleted: int + total_inventory: int + + +class UmeManagedDeleteResult(BaseModel): + deleted: int diff --git a/netx_api/ne_service.py b/netx_api/ne_service.py index eb29d82..5fe8287 100644 --- a/netx_api/ne_service.py +++ b/netx_api/ne_service.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime from io import BytesIO +import re from typing import Any import pandas as pd @@ -10,15 +11,18 @@ from sqlalchemy import or_ from sqlalchemy.orm import Session from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS -from .models import ManagedNE +from .models import ManagedNE, UmeInventoryNE from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret from .ne_schemas import ( + BatchAccountConfig, HopProxyConfig, ImportFailure, ImportResult, ManagedNeCreate, ManagedNeOut, ManagedNeUpdate, + UmeManagedDeleteResult, + UmeManagedSyncResult, ) from .ne_session_factory import default_bastion_username_template, default_hop_command_template @@ -35,6 +39,15 @@ IMPORT_COLUMNS = ( "remark", ) +UME_SYNC_SOURCE = "ume_sync" +UME_SYNC_TAG = "UME" +_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 _now() -> datetime: return datetime.utcnow() @@ -64,6 +77,48 @@ def _normalize_hop_target_auth_mode(mode: str) -> str: return m if m in ("bastion_managed", "manual") else "bastion_managed" +def _normalize_vendor(vendor: str) -> str: + raw = str(vendor or "").strip() + if not raw: + return "Other" + for item in SUPPORTED_VENDORS: + if item.lower() == raw.lower(): + return item + return "Other" + + +def _merge_tags(tags: str, *extras: str) -> str: + seen: set[str] = set() + out: list[str] = [] + for token in str(tags or "").split(): + t = token.strip() + if t and t not in seen: + seen.add(t) + out.append(t) + for extra in extras: + t = str(extra or "").strip() + if t and t not in seen: + seen.add(t) + out.append(t) + return " ".join(out) + + +def _infer_managed_ne_type_vendor(ne_type: str, vendor: str) -> tuple[str, str]: + raw_vendor = _normalize_vendor(vendor) + text = str(ne_type or "").strip() + for pattern, device_type, inferred_vendor in _BUILTIN_NE_TYPE_RULES: + if pattern.search(text): + dt = device_type if device_type in SUPPORTED_DEVICE_TYPES else "zte_zxros" + return dt, _normalize_vendor(inferred_vendor or raw_vendor) + if raw_vendor == "Huawei": + return "huawei", "Huawei" + if raw_vendor == "Cisco": + return "cisco_ios", "Cisco" + if raw_vendor == "ZTE": + return "zte_zxros", "ZTE" + return "zte_zxros", raw_vendor + + def _validate_hop_on_create(body: ManagedNeCreate) -> None: if not body.hop_enabled: return @@ -130,7 +185,10 @@ def _apply_hop_update(row: ManagedNE, data: dict[str, Any]) -> None: raise HTTPException(status_code=400, detail="hop_host_required") if not str(row.hop_username or "").strip(): raise HTTPException(status_code=400, detail="hop_username_required") - if not str(row.hop_password_enc or "").strip(): + if ( + not str(row.hop_password_enc or "").strip() + and _normalize_hop_target_auth_mode(row.hop_target_auth_mode) != "bastion_managed" + ): raise HTTPException(status_code=400, detail="hop_password_required") @@ -243,6 +301,8 @@ def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut: connect_status="unknown", tags=str(body.tags or "").strip(), remark=str(body.remark or "").strip(), + source="", + source_ref="", created_at=now, updated_at=now, ) @@ -310,15 +370,17 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> dict[str, Any]: """Apply the same jump-host (proxy) settings to multiple managed NEs.""" - _require_crypto() hop_host = str(hop.hop_host or "").strip() hop_user = str(hop.hop_username or "").strip() hop_pass = str(hop.hop_password or "").strip() + if hop_pass: + _require_crypto() if not hop_host: raise HTTPException(status_code=400, detail="hop_host_required") if not hop_user: raise HTTPException(status_code=400, detail="hop_username_required") - if not hop_pass: + hop_auth_mode = _normalize_hop_target_auth_mode(hop.hop_target_auth_mode) + if not hop_pass and hop_auth_mode != "bastion_managed": raise HTTPException(status_code=400, detail="hop_password_required") hop_vendor = _normalize_hop_vendor(hop.hop_vendor) @@ -338,7 +400,6 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d if missing: raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}") - enc = encrypt_secret(hop_pass) now = _now() for row in rows: row.hop_enabled = True @@ -347,10 +408,40 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d row.hop_port = int(hop.hop_port or 22) row.hop_protocol = _normalize_protocol(hop.hop_protocol) row.hop_username = hop_user - row.hop_password_enc = enc + if hop_pass: + row.hop_password_enc = encrypt_secret(hop_pass) row.hop_command_template = template row.hop_vrf = str(hop.hop_vrf or "").strip() - row.hop_target_auth_mode = _normalize_hop_target_auth_mode(hop.hop_target_auth_mode) + row.hop_target_auth_mode = hop_auth_mode + row.updated_at = now + db.commit() + return {"ok": True, "updated": len(rows)} + + +def batch_apply_account(db: Session, ids: list[str], account: BatchAccountConfig) -> dict[str, Any]: + user = str(account.username or "").strip() + pwd = str(account.password or "") + if not user and not pwd: + raise HTTPException(status_code=400, detail="username_or_password_required") + if pwd: + _require_crypto() + pwd_enc = encrypt_secret(pwd) + else: + pwd_enc = "" + ne_ids = [str(x).strip() for x in ids if str(x).strip()] + if not ne_ids: + raise HTTPException(status_code=400, detail="ids_required") + rows = db.query(ManagedNE).filter(ManagedNE.id.in_(ne_ids)).all() + found_ids = {str(r.id) for r in rows} + missing = [x for x in ne_ids if x not in found_ids] + if missing: + raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}") + now = _now() + for row in rows: + if user: + row.username = user + if pwd: + row.password_enc = pwd_enc row.updated_at = now db.commit() return {"ok": True, "updated": len(rows)} @@ -454,6 +545,71 @@ def batch_delete_managed_ne(db: Session, ids: list[str]) -> dict[str, Any]: return {"ok": True, "deleted": len(rows)} +def sync_ume_inventory_to_managed_ne(db: Session) -> UmeManagedSyncResult: + rows = db.query(UmeInventoryNE).all() + by_source_ref = { + str(x.source_ref or ""): x + for x in db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all() + } + inventory_ids = {str(x.ne_id or "").strip() for x in rows if str(x.ne_id or "").strip()} + inserted = 0 + updated = 0 + now = _now() + for inv in rows: + source_ref = str(inv.ne_id or "").strip() + ip = _normalize_ip(str(inv.ip_address or "")) + if not source_ref or not ip: + continue + existing = by_source_ref.get(source_ref) + if existing is None: + existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first() + device_type, vendor = _infer_managed_ne_type_vendor(str(inv.ne_type or ""), str(inv.vendor or "")) + display_name = str(inv.ne_name or "").strip() or ip + existing_tags = str(existing.tags or "").strip() if existing is not None else "" + if existing is None: + existing = ManagedNE( + ip_address=ip, + created_at=now, + source=UME_SYNC_SOURCE, + source_ref=source_ref, + ) + db.add(existing) + inserted += 1 + else: + updated += 1 + existing.name = display_name + existing.vendor = vendor + existing.device_type = device_type + existing.port = int(existing.port or 22 or 22) + existing.protocol = _normalize_protocol(str(existing.protocol or "ssh")) + existing.tags = _merge_tags(existing_tags, UME_SYNC_TAG) + existing.source = UME_SYNC_SOURCE + existing.source_ref = source_ref + existing.updated_at = now + deleted = 0 + for row in db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all(): + ref = str(row.source_ref or "").strip() + if not ref or ref not in inventory_ids: + db.delete(row) + deleted += 1 + db.commit() + return UmeManagedSyncResult( + inserted=inserted, + updated=updated, + deleted=deleted, + total_inventory=len(inventory_ids), + ) + + +def delete_ume_synced_managed_ne(db: Session) -> UmeManagedDeleteResult: + rows = db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all() + deleted = len(rows) + for row in rows: + db.delete(row) + db.commit() + return UmeManagedDeleteResult(deleted=deleted) + + def build_managed_ne_import_template(fmt: str = "xlsx") -> tuple[str, bytes, str]: """Return (filename, content, media_type) for bulk-import template.""" rows = [ diff --git a/web/src/components/UmeCliConnectPanel.tsx b/web/src/components/UmeCliConnectPanel.tsx index 4be38fc..0e4591e 100644 --- a/web/src/components/UmeCliConnectPanel.tsx +++ b/web/src/components/UmeCliConnectPanel.tsx @@ -205,7 +205,7 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl {!embedded ? (
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index c314c79..44e33be 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -188,6 +188,28 @@ const en = {
connectDetailTitle: "Connectivity test log",
connectDetailEmpty:
"No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).",
+ umeSync: {
+ sync: "Sync UME NEs",
+ syncing: "Syncing…",
+ delete: "Delete UME NEs",
+ deleting: "Deleting…",
+ deleteConfirm: "Delete all managed NEs created from UME sync? This cannot be undone.",
+ done: "UME sync done: {{inserted}} inserted, {{updated}} updated, {{deleted}} deleted, {{total}} inventory total",
+ deletedDone: "Deleted {{n}} UME-synced NEs",
+ },
+ account: {
+ batchAdd: "Batch add account",
+ batchByTag: "Batch account by tag",
+ batchTitle: "Batch add account",
+ batchHint: "Apply the account info below to {{n}} selected NE(s).",
+ batchByTagHint: "Apply the account info below to NEs under the selected tag; leave password blank to update only username.",
+ batchDone: "Account updated for {{n}} NE(s)",
+ selectRequired: "Select network elements first",
+ applying: "Applying…",
+ apply: "Apply to NEs",
+ usernameOrPasswordRequired: "Enter at least a username or a password",
+ passwordOptionalBatch: "leave blank to keep unchanged",
+ },
help:
"[Bulk import]\n" +
"· Required columns: device_type, ip, username, port, protocol, name, vendor. Download the template first.\n" +
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index ba05474..b4212df 100644
--- a/web/src/i18n/zh.ts
+++ b/web/src/i18n/zh.ts
@@ -186,6 +186,28 @@ const zh = {
connectDetail: "详情",
connectDetailTitle: "连通性测试日志",
connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。",
+ umeSync: {
+ sync: "同步 UME 网元",
+ syncing: "同步中…",
+ delete: "删除 UME 网元",
+ deleting: "删除中…",
+ deleteConfirm: "确定删除所有通过 UME 同步到网元管理中的网元?此操作不可恢复。",
+ done: "UME 同步完成:新增 {{inserted}},更新 {{updated}},删除 {{deleted}},UME 清单共 {{total}} 台",
+ deletedDone: "已删除 {{n}} 台 UME 同步网元",
+ },
+ account: {
+ batchAdd: "一键添加账号",
+ batchByTag: "按标签添加账号",
+ batchTitle: "批量添加账号",
+ batchHint: "将以下账号信息应用到已选中的 {{n}} 台网元。",
+ batchByTagHint: "将以下账号信息应用到指定标签下的网元;密码可留空,仅更新用户名。",
+ batchDone: "已为 {{n}} 台网元更新账号",
+ selectRequired: "请先勾选要配置账号的网元",
+ applying: "应用中…",
+ apply: "应用到网元",
+ usernameOrPasswordRequired: "用户名和密码至少填写一项",
+ passwordOptionalBatch: "留空则不修改",
+ },
help:
"【批量导入】\n" +
"· 必填列:device_type、ip、username、port、protocol、name、vendor;可先下载模板。\n" +
diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx
index a1510ba..428c149 100644
--- a/web/src/pages/NePage.tsx
+++ b/web/src/pages/NePage.tsx
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
+ batchApplyAccountManagedNe,
batchApplyHopManagedNe,
batchDeleteManagedNe,
connectTestManagedNe,
createManagedNe,
+ deleteUmeManagedNe,
deleteManagedNe,
fetchIdsByTag,
fetchManagedNe,
@@ -12,6 +14,7 @@ import {
fetchManagedNeStats,
importManagedNe,
managedNeImportTemplateUrl,
+ syncUmeManagedNe,
updateManagedNe,
type ManagedNeStats,
} from "../services/api";
@@ -53,6 +56,11 @@ type FormState = {
hop_target_auth_mode: "bastion_managed" | "manual";
};
+type AccountState = {
+ username: string;
+ password: string;
+};
+
const emptyForm = (): FormState => ({
name: "",
vendor: "ZTE",
@@ -76,6 +84,11 @@ const emptyForm = (): FormState => ({
hop_target_auth_mode: "bastion_managed",
});
+const emptyAccount = (): AccountState => ({
+ username: "",
+ password: "",
+});
+
function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial