Add UME-managed NE sync and batch account tools.

Sync UME inventory into managed NE with source-aware dedupe and cleanup, add one-click account updates for selected or tagged NEs, and expose the new managed NE actions in the web UI while keeping bulk bastion flows compatible with optional target passwords.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-02 10:52:54 +08:00
parent 775989cad4
commit 572b0cfd9d
11 changed files with 480 additions and 19 deletions

View file

@ -907,6 +907,8 @@ def on_startup() -> None:
conn.exec_driver_sql( conn.exec_driver_sql(
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed'" "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 managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''")
conn.exec_driver_sql( conn.exec_driver_sql(
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP" "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP"

View file

@ -9,18 +9,28 @@ from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
from .ne_connect import schedule_connect_tests from .ne_connect import schedule_connect_tests
from .ne_crypto import credentials_configured from .ne_crypto import credentials_configured
from .ne_exec import execute_managed_ne_commands 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 ( from .ne_service import (
batch_apply_account,
batch_apply_hop_proxy, batch_apply_hop_proxy,
build_managed_ne_import_template, build_managed_ne_import_template,
create_managed_ne, create_managed_ne,
batch_delete_managed_ne, batch_delete_managed_ne,
delete_ume_synced_managed_ne,
delete_managed_ne, delete_managed_ne,
get_ids_by_tag, get_ids_by_tag,
get_managed_ne, get_managed_ne,
get_managed_ne_stats, get_managed_ne_stats,
import_managed_ne, import_managed_ne,
list_managed_ne, list_managed_ne,
sync_ume_inventory_to_managed_ne,
update_managed_ne, update_managed_ne,
) )
from .models import ManagedNE 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) 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") @router.post("/batch-delete")
def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends(get_db)): def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends(get_db)):
return batch_delete_managed_ne(db, body.ids) 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") @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 or UME inventory NE and run read-only CLI (show/display/ping).""" """Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping)."""

View file

@ -286,6 +286,8 @@ class ManagedNE(Base):
site: Mapped[str] = mapped_column(String(256), default="") site: Mapped[str] = mapped_column(String(256), default="")
tags: Mapped[str] = mapped_column(String(512), default="") tags: Mapped[str] = mapped_column(String(512), default="")
remark: Mapped[str] = mapped_column(String(1024), 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_enabled: Mapped[bool] = mapped_column(default=False)
hop_vendor: Mapped[str] = mapped_column(String(32), default="zte") hop_vendor: Mapped[str] = mapped_column(String(32), default="zte")
hop_host: Mapped[str] = mapped_column(String(128), default="") hop_host: Mapped[str] = mapped_column(String(128), default="")

View file

@ -129,7 +129,7 @@ class HopProxyConfig(BaseModel):
hop_port: int = 22 hop_port: int = 22
hop_protocol: str = "ssh" hop_protocol: str = "ssh"
hop_username: str hop_username: str
hop_password: str hop_password: str = ""
hop_command_template: str = "" hop_command_template: str = ""
hop_vrf: str = "" hop_vrf: str = ""
hop_target_auth_mode: str = "bastion_managed" hop_target_auth_mode: str = "bastion_managed"
@ -140,6 +140,16 @@ class BatchHopApplyRequest(BaseModel):
hop: HopProxyConfig hop: HopProxyConfig
class BatchAccountConfig(BaseModel):
username: str = ""
password: str = ""
class BatchAccountApplyRequest(BaseModel):
ids: list[str] = Field(min_length=1)
account: BatchAccountConfig
class ImportFailure(BaseModel): class ImportFailure(BaseModel):
row: int row: int
reason: str reason: str
@ -149,3 +159,14 @@ class ImportResult(BaseModel):
inserted: int inserted: int
updated: int updated: int
failed: list[ImportFailure] failed: list[ImportFailure]
class UmeManagedSyncResult(BaseModel):
inserted: int
updated: int
deleted: int
total_inventory: int
class UmeManagedDeleteResult(BaseModel):
deleted: int

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
import re
from typing import Any from typing import Any
import pandas as pd import pandas as pd
@ -10,15 +11,18 @@ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS 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_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret
from .ne_schemas import ( from .ne_schemas import (
BatchAccountConfig,
HopProxyConfig, HopProxyConfig,
ImportFailure, ImportFailure,
ImportResult, ImportResult,
ManagedNeCreate, ManagedNeCreate,
ManagedNeOut, ManagedNeOut,
ManagedNeUpdate, ManagedNeUpdate,
UmeManagedDeleteResult,
UmeManagedSyncResult,
) )
from .ne_session_factory import default_bastion_username_template, default_hop_command_template from .ne_session_factory import default_bastion_username_template, default_hop_command_template
@ -35,6 +39,15 @@ IMPORT_COLUMNS = (
"remark", "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: def _now() -> datetime:
return datetime.utcnow() 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" 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: def _validate_hop_on_create(body: ManagedNeCreate) -> None:
if not body.hop_enabled: if not body.hop_enabled:
return 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") raise HTTPException(status_code=400, detail="hop_host_required")
if not str(row.hop_username or "").strip(): if not str(row.hop_username or "").strip():
raise HTTPException(status_code=400, detail="hop_username_required") 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") 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", connect_status="unknown",
tags=str(body.tags or "").strip(), tags=str(body.tags or "").strip(),
remark=str(body.remark or "").strip(), remark=str(body.remark or "").strip(),
source="",
source_ref="",
created_at=now, created_at=now,
updated_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]: 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.""" """Apply the same jump-host (proxy) settings to multiple managed NEs."""
_require_crypto()
hop_host = str(hop.hop_host or "").strip() hop_host = str(hop.hop_host or "").strip()
hop_user = str(hop.hop_username or "").strip() hop_user = str(hop.hop_username or "").strip()
hop_pass = str(hop.hop_password or "").strip() hop_pass = str(hop.hop_password or "").strip()
if hop_pass:
_require_crypto()
if not hop_host: if not hop_host:
raise HTTPException(status_code=400, detail="hop_host_required") raise HTTPException(status_code=400, detail="hop_host_required")
if not hop_user: if not hop_user:
raise HTTPException(status_code=400, detail="hop_username_required") 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") raise HTTPException(status_code=400, detail="hop_password_required")
hop_vendor = _normalize_hop_vendor(hop.hop_vendor) 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: if missing:
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}") raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
enc = encrypt_secret(hop_pass)
now = _now() now = _now()
for row in rows: for row in rows:
row.hop_enabled = True 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_port = int(hop.hop_port or 22)
row.hop_protocol = _normalize_protocol(hop.hop_protocol) row.hop_protocol = _normalize_protocol(hop.hop_protocol)
row.hop_username = hop_user 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_command_template = template
row.hop_vrf = str(hop.hop_vrf or "").strip() 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 row.updated_at = now
db.commit() db.commit()
return {"ok": True, "updated": len(rows)} 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)} 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]: def build_managed_ne_import_template(fmt: str = "xlsx") -> tuple[str, bytes, str]:
"""Return (filename, content, media_type) for bulk-import template.""" """Return (filename, content, media_type) for bulk-import template."""
rows = [ rows = [

View file

@ -205,7 +205,7 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
{!embedded ? ( {!embedded ? (
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("ume.cli.title")}</h2> <h2>{t("ume.cli.title")}</h2>
<HelpHint text={t("ume.cli.hint")} /> <HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} />
</div> </div>
) : null} ) : null}
<p className="form-field-hint" style={{ marginBottom: 12 }}> <p className="form-field-hint" style={{ marginBottom: 12 }}>

View file

@ -188,6 +188,28 @@ const en = {
connectDetailTitle: "Connectivity test log", connectDetailTitle: "Connectivity test log",
connectDetailEmpty: connectDetailEmpty:
"No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).", "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: help:
"[Bulk import]\n" + "[Bulk import]\n" +
"· Required columns: device_type, ip, username, port, protocol, name, vendor. Download the template first.\n" + "· Required columns: device_type, ip, username, port, protocol, name, vendor. Download the template first.\n" +

View file

@ -186,6 +186,28 @@ const zh = {
connectDetail: "详情", connectDetail: "详情",
connectDetailTitle: "连通性测试日志", connectDetailTitle: "连通性测试日志",
connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。", 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: help:
"【批量导入】\n" + "【批量导入】\n" +
"· 必填列:device_type、ip、username、port、protocol、name、vendor;可先下载模板。\n" + "· 必填列:device_type、ip、username、port、protocol、name、vendor;可先下载模板。\n" +

View file

@ -1,10 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
batchApplyAccountManagedNe,
batchApplyHopManagedNe, batchApplyHopManagedNe,
batchDeleteManagedNe, batchDeleteManagedNe,
connectTestManagedNe, connectTestManagedNe,
createManagedNe, createManagedNe,
deleteUmeManagedNe,
deleteManagedNe, deleteManagedNe,
fetchIdsByTag, fetchIdsByTag,
fetchManagedNe, fetchManagedNe,
@ -12,6 +14,7 @@ import {
fetchManagedNeStats, fetchManagedNeStats,
importManagedNe, importManagedNe,
managedNeImportTemplateUrl, managedNeImportTemplateUrl,
syncUmeManagedNe,
updateManagedNe, updateManagedNe,
type ManagedNeStats, type ManagedNeStats,
} from "../services/api"; } from "../services/api";
@ -53,6 +56,11 @@ type FormState = {
hop_target_auth_mode: "bastion_managed" | "manual"; hop_target_auth_mode: "bastion_managed" | "manual";
}; };
type AccountState = {
username: string;
password: string;
};
const emptyForm = (): FormState => ({ const emptyForm = (): FormState => ({
name: "", name: "",
vendor: "ZTE", vendor: "ZTE",
@ -76,6 +84,11 @@ const emptyForm = (): FormState => ({
hop_target_auth_mode: "bastion_managed", hop_target_auth_mode: "bastion_managed",
}); });
const emptyAccount = (): AccountState => ({
username: "",
password: "",
});
function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial<FormState> { function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial<FormState> {
if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) { if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) {
return {}; return {};
@ -118,15 +131,18 @@ export function NePage() {
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [batchHopOpen, setBatchHopOpen] = useState(false); const [batchHopOpen, setBatchHopOpen] = useState(false);
const [batchAccountOpen, setBatchAccountOpen] = useState(false);
const [editing, setEditing] = useState<ManagedNeItem | null>(null); const [editing, setEditing] = useState<ManagedNeItem | null>(null);
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields); const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
const [batchAccount, setBatchAccount] = useState<AccountState>(emptyAccount);
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null); const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
// --- bulk-by-tag dialog --- // --- bulk-by-tag dialog ---
const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false); const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false);
const [bulkTagAction, setBulkTagAction] = useState<"proxy" | "test">("proxy"); const [bulkTagAction, setBulkTagAction] = useState<"proxy" | "test" | "account">("proxy");
const [bulkTagSelected, setBulkTagSelected] = useState<string>(""); // "" = all, "__no_tag__" = no-tag NEs const [bulkTagSelected, setBulkTagSelected] = useState<string>(""); // "" = all, "__no_tag__" = no-tag NEs
const [bulkAccount, setBulkAccount] = useState<AccountState>(emptyAccount);
const metaQuery = useQuery({ const metaQuery = useQuery({
queryKey: queryKeys.managedNeMeta, queryKey: queryKeys.managedNeMeta,
@ -272,6 +288,52 @@ export function NePage() {
onError: (err) => showError(String(err)), onError: (err) => showError(String(err)),
}); });
const batchAccountMutation = useMutation({
mutationFn: () =>
batchApplyAccountManagedNe(selected, {
username: batchAccount.username.trim(),
password: batchAccount.password,
}),
onSuccess: async (res) => {
setBatchAccountOpen(false);
setBatchAccount(emptyAccount());
showOk(t("managedNe.account.batchDone", { n: res.updated }));
await invalidateList();
},
onError: (err) => showError(String(err)),
});
const umeSyncMutation = useMutation({
mutationFn: syncUmeManagedNe,
onSuccess: async (res) => {
showOk(
t("managedNe.umeSync.done", {
inserted: res.inserted,
updated: res.updated,
deleted: res.deleted,
total: res.total_inventory,
}),
);
await Promise.all([
invalidateList(),
queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }),
]);
},
onError: (err) => showError(String(err)),
});
const umeDeleteMutation = useMutation({
mutationFn: deleteUmeManagedNe,
onSuccess: async (res) => {
showOk(t("managedNe.umeSync.deletedDone", { n: res.deleted }));
await Promise.all([
invalidateList(),
queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }),
]);
},
onError: (err) => showError(String(err)),
});
const importMutation = useMutation({ const importMutation = useMutation({
mutationFn: importManagedNe, mutationFn: importManagedNe,
onSuccess: async (res) => { onSuccess: async (res) => {
@ -290,7 +352,7 @@ export function NePage() {
// Bulk-by-tag: fetch ids then run proxy/test // Bulk-by-tag: fetch ids then run proxy/test
const [bulkHop, setBulkHop] = useState<HopProxyFieldsState>(() => emptyHopProxyFields()); const [bulkHop, setBulkHop] = useState<HopProxyFieldsState>(() => emptyHopProxyFields());
const bulkByTagMutation = useMutation({ const bulkByTagMutation = useMutation({
mutationFn: async (params: { action: "proxy" | "test"; tag: string }) => { mutationFn: async (params: { action: "proxy" | "test" | "account"; tag: string }) => {
const apiTag = params.tag === "" ? null : params.tag; const apiTag = params.tag === "" ? null : params.tag;
const { ids } = await fetchIdsByTag(apiTag); const { ids } = await fetchIdsByTag(apiTag);
if (ids.length === 0) throw new Error(t("managedNe.stats.loadingIds")); if (ids.length === 0) throw new Error(t("managedNe.stats.loadingIds"));
@ -299,6 +361,13 @@ export function NePage() {
const res = await connectTestManagedNe(ids); const res = await connectTestManagedNe(ids);
return { type: "test" as const, n: res.submitted }; return { type: "test" as const, n: res.submitted };
} }
if (params.action === "account") {
const res = await batchApplyAccountManagedNe(ids, {
username: bulkAccount.username.trim(),
password: bulkAccount.password,
});
return { type: "account" as const, n: res.updated };
}
const res = await batchApplyHopManagedNe(ids, { const res = await batchApplyHopManagedNe(ids, {
hop_vendor: bulkHop.hop_vendor, hop_vendor: bulkHop.hop_vendor,
hop_host: bulkHop.hop_host.trim(), hop_host: bulkHop.hop_host.trim(),
@ -316,6 +385,7 @@ export function NePage() {
if (!res) return; if (!res) return;
setBulkTagModalOpen(false); setBulkTagModalOpen(false);
if (res.type === "test") showOk(t("managedNe.stats.testDone", { n: res.n })); if (res.type === "test") showOk(t("managedNe.stats.testDone", { n: res.n }));
else if (res.type === "account") showOk(t("managedNe.account.batchDone", { n: res.n }));
else showOk(t("managedNe.stats.proxyDone", { n: res.n })); else showOk(t("managedNe.stats.proxyDone", { n: res.n }));
await Promise.all([ await Promise.all([
invalidateList(), invalidateList(),
@ -437,7 +507,7 @@ export function NePage() {
<div className="ne-stats-card__header-actions"> <div className="ne-stats-card__header-actions">
<button <button
type="button" type="button"
disabled={!credsOk || bulkByTagMutation.isPending} disabled={bulkByTagMutation.isPending}
onClick={() => { onClick={() => {
setBulkTagAction("proxy"); setBulkTagAction("proxy");
setBulkHop(emptyHopProxyFields()); setBulkHop(emptyHopProxyFields());
@ -446,6 +516,17 @@ export function NePage() {
> >
{t("managedNe.stats.batchProxy")} {t("managedNe.stats.batchProxy")}
</button> </button>
<button
type="button"
disabled={bulkByTagMutation.isPending}
onClick={() => {
setBulkTagAction("account");
setBulkAccount(emptyAccount());
setBulkTagModalOpen(true);
}}
>
{t("managedNe.account.batchByTag")}
</button>
<button <button
type="button" type="button"
disabled={bulkByTagMutation.isPending} disabled={bulkByTagMutation.isPending}
@ -487,6 +568,24 @@ export function NePage() {
<button type="button" onClick={openCreate} disabled={!credsOk}> <button type="button" onClick={openCreate} disabled={!credsOk}>
{t("managedNe.add")} {t("managedNe.add")}
</button> </button>
<button
type="button"
onClick={() => umeSyncMutation.mutate()}
disabled={umeSyncMutation.isPending}
>
{umeSyncMutation.isPending ? t("managedNe.umeSync.syncing") : t("managedNe.umeSync.sync")}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => {
if (!window.confirm(t("managedNe.umeSync.deleteConfirm"))) return;
umeDeleteMutation.mutate();
}}
disabled={umeDeleteMutation.isPending}
>
{umeDeleteMutation.isPending ? t("managedNe.umeSync.deleting") : t("managedNe.umeSync.delete")}
</button>
<button <button
type="button" type="button"
onClick={() => window.location.assign(managedNeImportTemplateUrl("xlsx"))} onClick={() => window.location.assign(managedNeImportTemplateUrl("xlsx"))}
@ -520,7 +619,7 @@ export function NePage() {
</button> </button>
<button <button
type="button" type="button"
disabled={!credsOk || selected.length === 0 || batchHopMutation.isPending} disabled={selected.length === 0 || batchHopMutation.isPending}
onClick={() => { onClick={() => {
if (selected.length === 0) { if (selected.length === 0) {
showError(t("managedNe.hop.selectRequired")); showError(t("managedNe.hop.selectRequired"));
@ -532,6 +631,20 @@ export function NePage() {
> >
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")} {batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")}
</button> </button>
<button
type="button"
disabled={selected.length === 0 || batchAccountMutation.isPending}
onClick={() => {
if (selected.length === 0) {
showError(t("managedNe.account.selectRequired"));
return;
}
setBatchAccount(emptyAccount());
setBatchAccountOpen(true);
}}
>
{batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.batchAdd")}
</button>
<button <button
type="button" type="button"
className="btn btn--danger" className="btn btn--danger"
@ -873,7 +986,7 @@ export function NePage() {
showError(t("managedNe.hop.userRequired")); showError(t("managedNe.hop.userRequired"));
return; return;
} }
if (!batchHop.hop_password) { if (!batchHop.hop_password && batchHop.hop_target_auth_mode !== "bastion_managed") {
showError(t("managedNe.hop.passwordRequired")); showError(t("managedNe.hop.passwordRequired"));
return; return;
} }
@ -887,11 +1000,62 @@ export function NePage() {
</div> </div>
) : null} ) : null}
{batchAccountOpen ? (
<div className="modal-backdrop" role="presentation" onClick={() => setBatchAccountOpen(false)}>
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("managedNe.account.batchTitle")}</h3>
<p className="form-hint">{t("managedNe.account.batchHint", { n: selected.length })}</p>
<div className="form-grid">
<label>
<FormLabel>{t("managedNe.col.user")}</FormLabel>
<input
value={batchAccount.username}
onChange={(e) => setBatchAccount((prev) => ({ ...prev, username: e.target.value }))}
/>
</label>
<label>
<FormLabel>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
</FormLabel>
<input
type="password"
value={batchAccount.password}
onChange={(e) => setBatchAccount((prev) => ({ ...prev, password: e.target.value }))}
/>
</label>
</div>
<div className="modal__actions">
<button type="button" onClick={() => setBatchAccountOpen(false)}>
{t("managedNe.form.cancel")}
</button>
<button
type="button"
disabled={batchAccountMutation.isPending}
onClick={() => {
if (!batchAccount.username.trim() && !batchAccount.password) {
showError(t("managedNe.account.usernameOrPasswordRequired"));
return;
}
batchAccountMutation.mutate();
}}
>
{batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.apply")}
</button>
</div>
</div>
</div>
) : null}
{bulkTagModalOpen ? ( {bulkTagModalOpen ? (
<div className="modal-backdrop" role="presentation" onClick={() => setBulkTagModalOpen(false)}> <div className="modal-backdrop" role="presentation" onClick={() => setBulkTagModalOpen(false)}>
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}> <div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3> <h3>
{bulkTagAction === "proxy" ? t("managedNe.stats.batchProxy") : t("managedNe.stats.batchTest")} {bulkTagAction === "proxy"
? t("managedNe.stats.batchProxy")
: bulkTagAction === "account"
? t("managedNe.account.batchByTag")
: t("managedNe.stats.batchTest")}
{bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""} {bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""}
{bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""} {bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""}
{bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""} {bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}
@ -917,6 +1081,30 @@ export function NePage() {
<p className="form-hint">{t("managedNe.hop.batchHint", { n: "?" })}</p> <p className="form-hint">{t("managedNe.hop.batchHint", { n: "?" })}</p>
<HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} /> <HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} />
</> </>
) : bulkTagAction === "account" ? (
<>
<p className="form-hint">{t("managedNe.account.batchByTagHint")}</p>
<div className="form-grid">
<label>
<FormLabel>{t("managedNe.col.user")}</FormLabel>
<input
value={bulkAccount.username}
onChange={(e) => setBulkAccount((prev) => ({ ...prev, username: e.target.value }))}
/>
</label>
<label>
<FormLabel>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
</FormLabel>
<input
type="password"
value={bulkAccount.password}
onChange={(e) => setBulkAccount((prev) => ({ ...prev, password: e.target.value }))}
/>
</label>
</div>
</>
) : ( ) : (
<p className="form-hint">{t("managedNe.stats.confirm", { n: "?" })}</p> <p className="form-hint">{t("managedNe.stats.confirm", { n: "?" })}</p>
)} )}
@ -931,7 +1119,14 @@ export function NePage() {
if (bulkTagAction === "proxy") { if (bulkTagAction === "proxy") {
if (!bulkHop.hop_host.trim()) { showError(t("managedNe.hop.hostRequired")); return; } if (!bulkHop.hop_host.trim()) { showError(t("managedNe.hop.hostRequired")); return; }
if (!bulkHop.hop_username.trim()) { showError(t("managedNe.hop.userRequired")); return; } if (!bulkHop.hop_username.trim()) { showError(t("managedNe.hop.userRequired")); return; }
if (!bulkHop.hop_password) { showError(t("managedNe.hop.passwordRequired")); return; } if (!bulkHop.hop_password && bulkHop.hop_target_auth_mode !== "bastion_managed") {
showError(t("managedNe.hop.passwordRequired")); return;
}
}
if (bulkTagAction === "account") {
if (!bulkAccount.username.trim() && !bulkAccount.password) {
showError(t("managedNe.account.usernameOrPasswordRequired")); return;
}
} }
bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected }); bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected });
}} }}
@ -940,7 +1135,9 @@ export function NePage() {
? t("managedNe.stats.loadingIds") ? t("managedNe.stats.loadingIds")
: bulkTagAction === "proxy" : bulkTagAction === "proxy"
? t("managedNe.hop.apply") ? t("managedNe.hop.apply")
: t("managedNe.connect.run")} : bulkTagAction === "account"
? t("managedNe.account.apply")
: t("managedNe.connect.run")}
</button> </button>
</div> </div>
</div> </div>

View file

@ -984,7 +984,7 @@ export function UmePage() {
</span> </span>
<button <button
type="button" type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor })} onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll })}
disabled={keyAlertMonitorQuery.isFetching} disabled={keyAlertMonitorQuery.isFetching}
> >
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")} {keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}

View file

@ -239,6 +239,20 @@ export const batchApplyHopManagedNe = (
}, },
) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-hop", { ids, hop }); ) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-hop", { ids, hop });
export const batchApplyAccountManagedNe = (
ids: string[],
account: {
username?: string;
password?: string;
},
) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-account", { ids, account });
export const syncUmeManagedNe = () =>
apiPost<{ inserted: number; updated: number; deleted: number; total_inventory: number }>("/v1/managed-ne/ume-sync", {});
export const deleteUmeManagedNe = () =>
apiDelete<{ deleted: number }>("/v1/managed-ne/ume-sync");
export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") => export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") =>
`/v1/managed-ne/import/template?format=${format}`; `/v1/managed-ne/import/template?format=${format}`;