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(
"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"

View file

@ -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)."""

View file

@ -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="")

View file

@ -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

View file

@ -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 = [

View file

@ -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 }}>

View file

@ -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" +

View file

@ -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" +

View file

@ -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>

View file

@ -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")}

View file

@ -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}`;