From 572b0cfd9da6f731370645abd2f5c09e90c841d4 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 2 Jul 2026 10:52:54 +0800 Subject: [PATCH] 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 --- netx_api/main.py | 2 + netx_api/managed_ne_router.py | 27 ++- netx_api/models.py | 2 + netx_api/ne_schemas.py | 23 ++- netx_api/ne_service.py | 170 ++++++++++++++++- web/src/components/UmeCliConnectPanel.tsx | 2 +- web/src/i18n/en.ts | 22 +++ web/src/i18n/zh.ts | 22 +++ web/src/pages/NePage.tsx | 213 +++++++++++++++++++++- web/src/pages/UmePage.tsx | 2 +- web/src/services/api.ts | 14 ++ 11 files changed, 480 insertions(+), 19 deletions(-) diff --git a/netx_api/main.py b/netx_api/main.py index 813731d..72da24e 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -907,6 +907,8 @@ def on_startup() -> None: conn.exec_driver_sql( "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed'" ) + conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source VARCHAR(64) DEFAULT ''") + conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source_ref VARCHAR(128) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''") conn.exec_driver_sql( "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP" diff --git a/netx_api/managed_ne_router.py b/netx_api/managed_ne_router.py index ee10a25..a1b5629 100644 --- a/netx_api/managed_ne_router.py +++ b/netx_api/managed_ne_router.py @@ -9,18 +9,28 @@ from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS from .ne_connect import schedule_connect_tests from .ne_crypto import credentials_configured from .ne_exec import execute_managed_ne_commands -from .ne_schemas import BatchHopApplyRequest, ConnectTestRequest, ManagedNeCreate, ManagedNeExecRequest, ManagedNeUpdate +from .ne_schemas import ( + BatchAccountApplyRequest, + BatchHopApplyRequest, + ConnectTestRequest, + ManagedNeCreate, + ManagedNeExecRequest, + ManagedNeUpdate, +) from .ne_service import ( + batch_apply_account, batch_apply_hop_proxy, build_managed_ne_import_template, create_managed_ne, batch_delete_managed_ne, + delete_ume_synced_managed_ne, delete_managed_ne, get_ids_by_tag, get_managed_ne, get_managed_ne_stats, import_managed_ne, list_managed_ne, + sync_ume_inventory_to_managed_ne, update_managed_ne, ) from .models import ManagedNE @@ -111,11 +121,26 @@ def api_batch_apply_hop(body: BatchHopApplyRequest, db: Session = Depends(get_db return batch_apply_hop_proxy(db, body.ids, body.hop) +@router.post("/batch-account") +def api_batch_apply_account(body: BatchAccountApplyRequest, db: Session = Depends(get_db)): + return batch_apply_account(db, body.ids, body.account) + + @router.post("/batch-delete") def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends(get_db)): return batch_delete_managed_ne(db, body.ids) +@router.post("/ume-sync") +def api_sync_ume_inventory_to_managed_ne(db: Session = Depends(get_db)): + return sync_ume_inventory_to_managed_ne(db).model_dump() + + +@router.delete("/ume-sync") +def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)): + return delete_ume_synced_managed_ne(db).model_dump() + + @router.post("/exec") def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db)): """Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping).""" diff --git a/netx_api/models.py b/netx_api/models.py index eedf56b..aea6c03 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -286,6 +286,8 @@ class ManagedNE(Base): site: Mapped[str] = mapped_column(String(256), default="") tags: Mapped[str] = mapped_column(String(512), default="") remark: Mapped[str] = mapped_column(String(1024), default="") + source: Mapped[str] = mapped_column(String(64), default="", index=True) + source_ref: Mapped[str] = mapped_column(String(128), default="", index=True) hop_enabled: Mapped[bool] = mapped_column(default=False) hop_vendor: Mapped[str] = mapped_column(String(32), default="zte") hop_host: Mapped[str] = mapped_column(String(128), default="") diff --git a/netx_api/ne_schemas.py b/netx_api/ne_schemas.py index a5e2342..f9efa76 100644 --- a/netx_api/ne_schemas.py +++ b/netx_api/ne_schemas.py @@ -129,7 +129,7 @@ class HopProxyConfig(BaseModel): hop_port: int = 22 hop_protocol: str = "ssh" hop_username: str - hop_password: str + hop_password: str = "" hop_command_template: str = "" hop_vrf: str = "" hop_target_auth_mode: str = "bastion_managed" @@ -140,6 +140,16 @@ class BatchHopApplyRequest(BaseModel): hop: HopProxyConfig +class BatchAccountConfig(BaseModel): + username: str = "" + password: str = "" + + +class BatchAccountApplyRequest(BaseModel): + ids: list[str] = Field(min_length=1) + account: BatchAccountConfig + + class ImportFailure(BaseModel): row: int reason: str @@ -149,3 +159,14 @@ class ImportResult(BaseModel): inserted: int updated: int failed: list[ImportFailure] + + +class UmeManagedSyncResult(BaseModel): + inserted: int + updated: int + deleted: int + total_inventory: int + + +class UmeManagedDeleteResult(BaseModel): + deleted: int diff --git a/netx_api/ne_service.py b/netx_api/ne_service.py index eb29d82..5fe8287 100644 --- a/netx_api/ne_service.py +++ b/netx_api/ne_service.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime from io import BytesIO +import re from typing import Any import pandas as pd @@ -10,15 +11,18 @@ from sqlalchemy import or_ from sqlalchemy.orm import Session from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS -from .models import ManagedNE +from .models import ManagedNE, UmeInventoryNE from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret from .ne_schemas import ( + BatchAccountConfig, HopProxyConfig, ImportFailure, ImportResult, ManagedNeCreate, ManagedNeOut, ManagedNeUpdate, + UmeManagedDeleteResult, + UmeManagedSyncResult, ) from .ne_session_factory import default_bastion_username_template, default_hop_command_template @@ -35,6 +39,15 @@ IMPORT_COLUMNS = ( "remark", ) +UME_SYNC_SOURCE = "ume_sync" +UME_SYNC_TAG = "UME" +_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [ + (re.compile(r"ZXR|ZXCTN|M6000|\bBN\b", re.I), "zte_zxros", "ZTE"), + (re.compile(r"NE40|CE\b|ATN|MA5800|OptiX", re.I), "huawei", "Huawei"), + (re.compile(r"ASR|NCS|IOS.?XR|XR\b", re.I), "cisco_xr", "Cisco"), + (re.compile(r"Catalyst|Nexus|C9[0-9]{3}|ISR", re.I), "cisco_ios", "Cisco"), +] + def _now() -> datetime: return datetime.utcnow() @@ -64,6 +77,48 @@ def _normalize_hop_target_auth_mode(mode: str) -> str: return m if m in ("bastion_managed", "manual") else "bastion_managed" +def _normalize_vendor(vendor: str) -> str: + raw = str(vendor or "").strip() + if not raw: + return "Other" + for item in SUPPORTED_VENDORS: + if item.lower() == raw.lower(): + return item + return "Other" + + +def _merge_tags(tags: str, *extras: str) -> str: + seen: set[str] = set() + out: list[str] = [] + for token in str(tags or "").split(): + t = token.strip() + if t and t not in seen: + seen.add(t) + out.append(t) + for extra in extras: + t = str(extra or "").strip() + if t and t not in seen: + seen.add(t) + out.append(t) + return " ".join(out) + + +def _infer_managed_ne_type_vendor(ne_type: str, vendor: str) -> tuple[str, str]: + raw_vendor = _normalize_vendor(vendor) + text = str(ne_type or "").strip() + for pattern, device_type, inferred_vendor in _BUILTIN_NE_TYPE_RULES: + if pattern.search(text): + dt = device_type if device_type in SUPPORTED_DEVICE_TYPES else "zte_zxros" + return dt, _normalize_vendor(inferred_vendor or raw_vendor) + if raw_vendor == "Huawei": + return "huawei", "Huawei" + if raw_vendor == "Cisco": + return "cisco_ios", "Cisco" + if raw_vendor == "ZTE": + return "zte_zxros", "ZTE" + return "zte_zxros", raw_vendor + + def _validate_hop_on_create(body: ManagedNeCreate) -> None: if not body.hop_enabled: return @@ -130,7 +185,10 @@ def _apply_hop_update(row: ManagedNE, data: dict[str, Any]) -> None: raise HTTPException(status_code=400, detail="hop_host_required") if not str(row.hop_username or "").strip(): raise HTTPException(status_code=400, detail="hop_username_required") - if not str(row.hop_password_enc or "").strip(): + if ( + not str(row.hop_password_enc or "").strip() + and _normalize_hop_target_auth_mode(row.hop_target_auth_mode) != "bastion_managed" + ): raise HTTPException(status_code=400, detail="hop_password_required") @@ -243,6 +301,8 @@ def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut: connect_status="unknown", tags=str(body.tags or "").strip(), remark=str(body.remark or "").strip(), + source="", + source_ref="", created_at=now, updated_at=now, ) @@ -310,15 +370,17 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> dict[str, Any]: """Apply the same jump-host (proxy) settings to multiple managed NEs.""" - _require_crypto() hop_host = str(hop.hop_host or "").strip() hop_user = str(hop.hop_username or "").strip() hop_pass = str(hop.hop_password or "").strip() + if hop_pass: + _require_crypto() if not hop_host: raise HTTPException(status_code=400, detail="hop_host_required") if not hop_user: raise HTTPException(status_code=400, detail="hop_username_required") - if not hop_pass: + hop_auth_mode = _normalize_hop_target_auth_mode(hop.hop_target_auth_mode) + if not hop_pass and hop_auth_mode != "bastion_managed": raise HTTPException(status_code=400, detail="hop_password_required") hop_vendor = _normalize_hop_vendor(hop.hop_vendor) @@ -338,7 +400,6 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d if missing: raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}") - enc = encrypt_secret(hop_pass) now = _now() for row in rows: row.hop_enabled = True @@ -347,10 +408,40 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d row.hop_port = int(hop.hop_port or 22) row.hop_protocol = _normalize_protocol(hop.hop_protocol) row.hop_username = hop_user - row.hop_password_enc = enc + if hop_pass: + row.hop_password_enc = encrypt_secret(hop_pass) row.hop_command_template = template row.hop_vrf = str(hop.hop_vrf or "").strip() - row.hop_target_auth_mode = _normalize_hop_target_auth_mode(hop.hop_target_auth_mode) + row.hop_target_auth_mode = hop_auth_mode + row.updated_at = now + db.commit() + return {"ok": True, "updated": len(rows)} + + +def batch_apply_account(db: Session, ids: list[str], account: BatchAccountConfig) -> dict[str, Any]: + user = str(account.username or "").strip() + pwd = str(account.password or "") + if not user and not pwd: + raise HTTPException(status_code=400, detail="username_or_password_required") + if pwd: + _require_crypto() + pwd_enc = encrypt_secret(pwd) + else: + pwd_enc = "" + ne_ids = [str(x).strip() for x in ids if str(x).strip()] + if not ne_ids: + raise HTTPException(status_code=400, detail="ids_required") + rows = db.query(ManagedNE).filter(ManagedNE.id.in_(ne_ids)).all() + found_ids = {str(r.id) for r in rows} + missing = [x for x in ne_ids if x not in found_ids] + if missing: + raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}") + now = _now() + for row in rows: + if user: + row.username = user + if pwd: + row.password_enc = pwd_enc row.updated_at = now db.commit() return {"ok": True, "updated": len(rows)} @@ -454,6 +545,71 @@ def batch_delete_managed_ne(db: Session, ids: list[str]) -> dict[str, Any]: return {"ok": True, "deleted": len(rows)} +def sync_ume_inventory_to_managed_ne(db: Session) -> UmeManagedSyncResult: + rows = db.query(UmeInventoryNE).all() + by_source_ref = { + str(x.source_ref or ""): x + for x in db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all() + } + inventory_ids = {str(x.ne_id or "").strip() for x in rows if str(x.ne_id or "").strip()} + inserted = 0 + updated = 0 + now = _now() + for inv in rows: + source_ref = str(inv.ne_id or "").strip() + ip = _normalize_ip(str(inv.ip_address or "")) + if not source_ref or not ip: + continue + existing = by_source_ref.get(source_ref) + if existing is None: + existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first() + device_type, vendor = _infer_managed_ne_type_vendor(str(inv.ne_type or ""), str(inv.vendor or "")) + display_name = str(inv.ne_name or "").strip() or ip + existing_tags = str(existing.tags or "").strip() if existing is not None else "" + if existing is None: + existing = ManagedNE( + ip_address=ip, + created_at=now, + source=UME_SYNC_SOURCE, + source_ref=source_ref, + ) + db.add(existing) + inserted += 1 + else: + updated += 1 + existing.name = display_name + existing.vendor = vendor + existing.device_type = device_type + existing.port = int(existing.port or 22 or 22) + existing.protocol = _normalize_protocol(str(existing.protocol or "ssh")) + existing.tags = _merge_tags(existing_tags, UME_SYNC_TAG) + existing.source = UME_SYNC_SOURCE + existing.source_ref = source_ref + existing.updated_at = now + deleted = 0 + for row in db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all(): + ref = str(row.source_ref or "").strip() + if not ref or ref not in inventory_ids: + db.delete(row) + deleted += 1 + db.commit() + return UmeManagedSyncResult( + inserted=inserted, + updated=updated, + deleted=deleted, + total_inventory=len(inventory_ids), + ) + + +def delete_ume_synced_managed_ne(db: Session) -> UmeManagedDeleteResult: + rows = db.query(ManagedNE).filter(ManagedNE.source == UME_SYNC_SOURCE).all() + deleted = len(rows) + for row in rows: + db.delete(row) + db.commit() + return UmeManagedDeleteResult(deleted=deleted) + + def build_managed_ne_import_template(fmt: str = "xlsx") -> tuple[str, bytes, str]: """Return (filename, content, media_type) for bulk-import template.""" rows = [ diff --git a/web/src/components/UmeCliConnectPanel.tsx b/web/src/components/UmeCliConnectPanel.tsx index 4be38fc..0e4591e 100644 --- a/web/src/components/UmeCliConnectPanel.tsx +++ b/web/src/components/UmeCliConnectPanel.tsx @@ -205,7 +205,7 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl {!embedded ? (

{t("ume.cli.title")}

- +
) : null}

diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c314c79..44e33be 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -188,6 +188,28 @@ const en = { connectDetailTitle: "Connectivity test log", connectDetailEmpty: "No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).", + umeSync: { + sync: "Sync UME NEs", + syncing: "Syncing…", + delete: "Delete UME NEs", + deleting: "Deleting…", + deleteConfirm: "Delete all managed NEs created from UME sync? This cannot be undone.", + done: "UME sync done: {{inserted}} inserted, {{updated}} updated, {{deleted}} deleted, {{total}} inventory total", + deletedDone: "Deleted {{n}} UME-synced NEs", + }, + account: { + batchAdd: "Batch add account", + batchByTag: "Batch account by tag", + batchTitle: "Batch add account", + batchHint: "Apply the account info below to {{n}} selected NE(s).", + batchByTagHint: "Apply the account info below to NEs under the selected tag; leave password blank to update only username.", + batchDone: "Account updated for {{n}} NE(s)", + selectRequired: "Select network elements first", + applying: "Applying…", + apply: "Apply to NEs", + usernameOrPasswordRequired: "Enter at least a username or a password", + passwordOptionalBatch: "leave blank to keep unchanged", + }, help: "[Bulk import]\n" + "· Required columns: device_type, ip, username, port, protocol, name, vendor. Download the template first.\n" + diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index ba05474..b4212df 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -186,6 +186,28 @@ const zh = { connectDetail: "详情", connectDetailTitle: "连通性测试日志", connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。", + umeSync: { + sync: "同步 UME 网元", + syncing: "同步中…", + delete: "删除 UME 网元", + deleting: "删除中…", + deleteConfirm: "确定删除所有通过 UME 同步到网元管理中的网元?此操作不可恢复。", + done: "UME 同步完成:新增 {{inserted}},更新 {{updated}},删除 {{deleted}},UME 清单共 {{total}} 台", + deletedDone: "已删除 {{n}} 台 UME 同步网元", + }, + account: { + batchAdd: "一键添加账号", + batchByTag: "按标签添加账号", + batchTitle: "批量添加账号", + batchHint: "将以下账号信息应用到已选中的 {{n}} 台网元。", + batchByTagHint: "将以下账号信息应用到指定标签下的网元;密码可留空,仅更新用户名。", + batchDone: "已为 {{n}} 台网元更新账号", + selectRequired: "请先勾选要配置账号的网元", + applying: "应用中…", + apply: "应用到网元", + usernameOrPasswordRequired: "用户名和密码至少填写一项", + passwordOptionalBatch: "留空则不修改", + }, help: "【批量导入】\n" + "· 必填列:device_type、ip、username、port、protocol、name、vendor;可先下载模板。\n" + diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index a1510ba..428c149 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + batchApplyAccountManagedNe, batchApplyHopManagedNe, batchDeleteManagedNe, connectTestManagedNe, createManagedNe, + deleteUmeManagedNe, deleteManagedNe, fetchIdsByTag, fetchManagedNe, @@ -12,6 +14,7 @@ import { fetchManagedNeStats, importManagedNe, managedNeImportTemplateUrl, + syncUmeManagedNe, updateManagedNe, type ManagedNeStats, } from "../services/api"; @@ -53,6 +56,11 @@ type FormState = { hop_target_auth_mode: "bastion_managed" | "manual"; }; +type AccountState = { + username: string; + password: string; +}; + const emptyForm = (): FormState => ({ name: "", vendor: "ZTE", @@ -76,6 +84,11 @@ const emptyForm = (): FormState => ({ hop_target_auth_mode: "bastion_managed", }); +const emptyAccount = (): AccountState => ({ + username: "", + password: "", +}); + function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial { 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([]); const [modalOpen, setModalOpen] = useState(false); const [batchHopOpen, setBatchHopOpen] = useState(false); + const [batchAccountOpen, setBatchAccountOpen] = useState(false); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm); const [batchHop, setBatchHop] = useState(emptyHopProxyFields); + const [batchAccount, setBatchAccount] = useState(emptyAccount); const [connectDetailRow, setConnectDetailRow] = useState(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(""); // "" = all, "__no_tag__" = no-tag NEs + const [bulkAccount, setBulkAccount] = useState(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(() => 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() {

+ + + + + +
+ + + ) : null} + {bulkTagModalOpen ? (
setBulkTagModalOpen(false)}>
e.stopPropagation()}>

- {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() {

{t("managedNe.hop.batchHint", { n: "?" })}

setBulkHop((prev) => ({ ...prev, ...patch }))} /> + ) : bulkTagAction === "account" ? ( + <> +

{t("managedNe.account.batchByTagHint")}

+
+ + +
+ ) : (

{t("managedNe.stats.confirm", { n: "?" })}

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

diff --git a/web/src/pages/UmePage.tsx b/web/src/pages/UmePage.tsx index 7f832ac..3726c07 100644 --- a/web/src/pages/UmePage.tsx +++ b/web/src/pages/UmePage.tsx @@ -984,7 +984,7 @@ export function UmePage() {