mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 10:40:46 +08:00
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:
parent
775989cad4
commit
572b0cfd9d
11 changed files with 480 additions and 19 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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="")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
|
|||
{!embedded ? (
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("ume.cli.title")}</h2>
|
||||
<HelpHint text={t("ume.cli.hint")} />
|
||||
<HelpHint text={t("ume.cli.hint")} ariaLabel={t("common.help")} />
|
||||
</div>
|
||||
) : null}
|
||||
<p className="form-field-hint" style={{ marginBottom: 12 }}>
|
||||
|
|
|
|||
|
|
@ -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" +
|
||||
|
|
|
|||
|
|
@ -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" +
|
||||
|
|
|
|||
|
|
@ -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<FormState> {
|
||||
if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) {
|
||||
return {};
|
||||
|
|
@ -118,15 +131,18 @@ export function NePage() {
|
|||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [batchHopOpen, setBatchHopOpen] = useState(false);
|
||||
const [batchAccountOpen, setBatchAccountOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ManagedNeItem | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
|
||||
const [batchAccount, setBatchAccount] = useState<AccountState>(emptyAccount);
|
||||
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
|
||||
|
||||
// --- bulk-by-tag dialog ---
|
||||
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 [bulkAccount, setBulkAccount] = useState<AccountState>(emptyAccount);
|
||||
|
||||
const metaQuery = useQuery({
|
||||
queryKey: queryKeys.managedNeMeta,
|
||||
|
|
@ -272,6 +288,52 @@ export function NePage() {
|
|||
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({
|
||||
mutationFn: importManagedNe,
|
||||
onSuccess: async (res) => {
|
||||
|
|
@ -290,7 +352,7 @@ export function NePage() {
|
|||
// Bulk-by-tag: fetch ids then run proxy/test
|
||||
const [bulkHop, setBulkHop] = useState<HopProxyFieldsState>(() => emptyHopProxyFields());
|
||||
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 { ids } = await fetchIdsByTag(apiTag);
|
||||
if (ids.length === 0) throw new Error(t("managedNe.stats.loadingIds"));
|
||||
|
|
@ -299,6 +361,13 @@ export function NePage() {
|
|||
const res = await connectTestManagedNe(ids);
|
||||
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, {
|
||||
hop_vendor: bulkHop.hop_vendor,
|
||||
hop_host: bulkHop.hop_host.trim(),
|
||||
|
|
@ -316,6 +385,7 @@ export function NePage() {
|
|||
if (!res) return;
|
||||
setBulkTagModalOpen(false);
|
||||
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 }));
|
||||
await Promise.all([
|
||||
invalidateList(),
|
||||
|
|
@ -437,7 +507,7 @@ export function NePage() {
|
|||
<div className="ne-stats-card__header-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!credsOk || bulkByTagMutation.isPending}
|
||||
disabled={bulkByTagMutation.isPending}
|
||||
onClick={() => {
|
||||
setBulkTagAction("proxy");
|
||||
setBulkHop(emptyHopProxyFields());
|
||||
|
|
@ -446,6 +516,17 @@ export function NePage() {
|
|||
>
|
||||
{t("managedNe.stats.batchProxy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkByTagMutation.isPending}
|
||||
onClick={() => {
|
||||
setBulkTagAction("account");
|
||||
setBulkAccount(emptyAccount());
|
||||
setBulkTagModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("managedNe.account.batchByTag")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkByTagMutation.isPending}
|
||||
|
|
@ -487,6 +568,24 @@ export function NePage() {
|
|||
<button type="button" onClick={openCreate} disabled={!credsOk}>
|
||||
{t("managedNe.add")}
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => window.location.assign(managedNeImportTemplateUrl("xlsx"))}
|
||||
|
|
@ -520,7 +619,7 @@ export function NePage() {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!credsOk || selected.length === 0 || batchHopMutation.isPending}
|
||||
disabled={selected.length === 0 || batchHopMutation.isPending}
|
||||
onClick={() => {
|
||||
if (selected.length === 0) {
|
||||
showError(t("managedNe.hop.selectRequired"));
|
||||
|
|
@ -532,6 +631,20 @@ export function NePage() {
|
|||
>
|
||||
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")}
|
||||
</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
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
|
|
@ -873,7 +986,7 @@ export function NePage() {
|
|||
showError(t("managedNe.hop.userRequired"));
|
||||
return;
|
||||
}
|
||||
if (!batchHop.hop_password) {
|
||||
if (!batchHop.hop_password && batchHop.hop_target_auth_mode !== "bastion_managed") {
|
||||
showError(t("managedNe.hop.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -887,11 +1000,62 @@ export function NePage() {
|
|||
</div>
|
||||
) : 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 ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setBulkTagModalOpen(false)}>
|
||||
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<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 === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""}
|
||||
{bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}
|
||||
|
|
@ -917,6 +1081,30 @@ export function NePage() {
|
|||
<p className="form-hint">{t("managedNe.hop.batchHint", { n: "?" })}</p>
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -931,7 +1119,14 @@ export function NePage() {
|
|||
if (bulkTagAction === "proxy") {
|
||||
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_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 });
|
||||
}}
|
||||
|
|
@ -940,7 +1135,9 @@ export function NePage() {
|
|||
? t("managedNe.stats.loadingIds")
|
||||
: bulkTagAction === "proxy"
|
||||
? t("managedNe.hop.apply")
|
||||
: t("managedNe.connect.run")}
|
||||
: bulkTagAction === "account"
|
||||
? t("managedNe.account.apply")
|
||||
: t("managedNe.connect.run")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -984,7 +984,7 @@ export function UmePage() {
|
|||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor })}
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll })}
|
||||
disabled={keyAlertMonitorQuery.isFetching}
|
||||
>
|
||||
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,20 @@ export const batchApplyHopManagedNe = (
|
|||
},
|
||||
) => 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") =>
|
||||
`/v1/managed-ne/import/template?format=${format}`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue