From d395aa71744e4a203eeaa58d6a8357db7cadbf7c Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 28 May 2026 17:28:14 +0800 Subject: [PATCH] feat(ne): batch apply jump proxy on selected NEs Add POST /v1/managed-ne/batch-hop, shared HopProxyFields form, and toolbar button next to connectivity test. Co-authored-by: Cursor --- netx_api/managed_ne_router.py | 8 +- netx_api/ne_schemas.py | 18 +++ netx_api/ne_service.py | 54 +++++++- web/src/components/HopProxyFields.tsx | 134 ++++++++++++++++++++ web/src/i18n/en.ts | 7 ++ web/src/i18n/zh.ts | 7 ++ web/src/pages/NePage.tsx | 169 ++++++++++++++------------ web/src/services/api.ts | 14 +++ 8 files changed, 329 insertions(+), 82 deletions(-) create mode 100644 web/src/components/HopProxyFields.tsx diff --git a/netx_api/managed_ne_router.py b/netx_api/managed_ne_router.py index 75b25e8..ef11f3e 100644 --- a/netx_api/managed_ne_router.py +++ b/netx_api/managed_ne_router.py @@ -8,8 +8,9 @@ from .db import get_db from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS from .ne_connect import schedule_connect_tests from .ne_crypto import credentials_configured -from .ne_schemas import ConnectTestRequest, ManagedNeCreate, ManagedNeUpdate +from .ne_schemas import BatchHopApplyRequest, ConnectTestRequest, ManagedNeCreate, ManagedNeUpdate from .ne_service import ( + batch_apply_hop_proxy, build_managed_ne_import_template, create_managed_ne, delete_managed_ne, @@ -90,6 +91,11 @@ async def api_import_managed_ne(file: UploadFile = File(...), db: Session = Depe return import_managed_ne(db, content, file.filename or "import.xlsx").model_dump() +@router.post("/batch-hop") +def api_batch_apply_hop(body: BatchHopApplyRequest, db: Session = Depends(get_db)): + return batch_apply_hop_proxy(db, body.ids, body.hop) + + @router.post("/connect-test") def api_connect_test(body: ConnectTestRequest, db: Session = Depends(get_db)): ids = [str(x).strip() for x in body.ids if str(x).strip()] diff --git a/netx_api/ne_schemas.py b/netx_api/ne_schemas.py index 2e9089a..ac94de1 100644 --- a/netx_api/ne_schemas.py +++ b/netx_api/ne_schemas.py @@ -108,6 +108,24 @@ class ConnectTestRequest(BaseModel): ids: list[str] = Field(min_length=1) +class HopProxyConfig(BaseModel): + """Shared jump-host (proxy) settings applied to one or many NEs.""" + + hop_vendor: str = "zte" + hop_host: str + hop_port: int = 22 + hop_protocol: str = "ssh" + hop_username: str + hop_password: str + hop_command_template: str = "" + hop_vrf: str = "" + + +class BatchHopApplyRequest(BaseModel): + ids: list[str] = Field(min_length=1) + hop: HopProxyConfig + + class ImportFailure(BaseModel): row: int reason: str diff --git a/netx_api/ne_service.py b/netx_api/ne_service.py index ca8641b..2920f06 100644 --- a/netx_api/ne_service.py +++ b/netx_api/ne_service.py @@ -11,7 +11,15 @@ from sqlalchemy.orm import Session from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS from .models import ManagedNE from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret -from .ne_schemas import ImportFailure, ImportResult, ManagedNeCreate, ManagedNeOut, ManagedNeUpdate +from .ne_schemas import ( + HopProxyConfig, + ImportFailure, + ImportResult, + ManagedNeCreate, + ManagedNeOut, + ManagedNeUpdate, +) +from .ne_session_factory import default_zte_hop_template IMPORT_COLUMNS = ( "device_type", @@ -266,6 +274,50 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed return row_to_out(row) +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 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: + raise HTTPException(status_code=400, detail="hop_password_required") + + template = str(hop.hop_command_template or "").strip() + if not template: + template = default_zte_hop_template(hop.hop_protocol, hop.hop_vrf) + + 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])}") + + enc = encrypt_secret(hop_pass) + now = _now() + for row in rows: + row.hop_enabled = True + row.hop_vendor = _normalize_hop_vendor(hop.hop_vendor) + row.hop_host = hop_host + 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 + row.hop_command_template = template + row.hop_vrf = str(hop.hop_vrf or "").strip() + row.updated_at = now + db.commit() + return {"ok": True, "updated": len(rows)} + + def delete_managed_ne(db: Session, ne_id: str) -> dict[str, bool]: row = db.get(ManagedNE, ne_id) if not row: diff --git a/web/src/components/HopProxyFields.tsx b/web/src/components/HopProxyFields.tsx new file mode 100644 index 0000000..c07155c --- /dev/null +++ b/web/src/components/HopProxyFields.tsx @@ -0,0 +1,134 @@ +import type { ReactNode } from "react"; +import { useI18n } from "../i18n"; +import { isAutoHopTemplate, zteHopTemplate } from "../utils/zteHop"; + +export type HopProxyFieldsState = { + hop_host: string; + hop_port: number; + hop_protocol: string; + hop_username: string; + hop_password: string; + hop_command_template: string; + hop_vrf: string; +}; + +export const emptyHopProxyFields = (): HopProxyFieldsState => ({ + hop_host: "", + hop_port: 22, + hop_protocol: "ssh", + hop_username: "", + hop_password: "", + hop_command_template: zteHopTemplate("ssh", ""), + hop_vrf: "", +}); + +function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) { + return ( + + {children} + {required ? ( + + ) : null} + + ); +} + +function applyHopTemplate( + prev: HopProxyFieldsState, + protocol: string, + vrf: string, + force = false, +): Partial { + if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_protocol, prev.hop_vrf)) { + return {}; + } + return { hop_command_template: zteHopTemplate(protocol, vrf) }; +} + +type Props = { + value: HopProxyFieldsState; + onChange: (patch: Partial) => void; + hopPasswordRequired?: boolean; + hopPasswordOptional?: boolean; +}; + +export function HopProxyFields({ + value, + onChange, + hopPasswordRequired = true, + hopPasswordOptional = false, +}: Props) { + const { t } = useI18n(); + + const set = (patch: Partial) => onChange(patch); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1ad908e..76d2671 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -192,6 +192,13 @@ const en = { hostRequired: "Jump host is required", userRequired: "Jump username is required", passwordRequired: "Jump password is required", + batchAdd: "Add proxy", + batchTitle: "Batch add jump proxy", + batchHint: "Apply the jump-host settings below to {{n}} selected NE(s) (overwrites existing proxy config).", + batchDone: "Proxy applied to {{n}} NE(s)", + selectRequired: "Select network elements first", + applying: "Applying…", + apply: "Apply to selected", }, }, ume: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1b6b508..4403c04 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -191,6 +191,13 @@ const zh = { hostRequired: "请填写跳板地址", userRequired: "请填写跳板用户名", passwordRequired: "请填写跳板密码", + batchAdd: "添加代理", + batchTitle: "批量添加代理", + batchHint: "将以下跳板配置应用到已选中的 {{n}} 台网元(会覆盖原有跳板设置)。", + batchDone: "已为 {{n}} 台网元配置代理", + selectRequired: "请先勾选要配置的网元", + applying: "应用中…", + apply: "应用到选中网元", }, }, ume: { diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index 2612744..7255259 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + batchApplyHopManagedNe, connectTestManagedNe, createManagedNe, deleteManagedNe, @@ -10,6 +11,7 @@ import { managedNeImportTemplateUrl, updateManagedNe, } from "../services/api"; +import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields"; import { queryKeys } from "../constants/queryKeys"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; @@ -101,8 +103,10 @@ export function NePage() { const [pageSize, setPageSize] = useState(50); const [selected, setSelected] = useState([]); const [modalOpen, setModalOpen] = useState(false); + const [batchHopOpen, setBatchHopOpen] = useState(false); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm); + const [batchHop, setBatchHop] = useState(emptyHopProxyFields); const metaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, @@ -196,6 +200,27 @@ export function NePage() { onError: (err) => showError(String(err)), }); + const batchHopMutation = useMutation({ + mutationFn: () => + batchApplyHopManagedNe(selected, { + hop_vendor: "zte", + hop_host: batchHop.hop_host.trim(), + hop_port: batchHop.hop_port, + hop_protocol: batchHop.hop_protocol, + hop_username: batchHop.hop_username.trim(), + hop_password: batchHop.hop_password, + hop_command_template: batchHop.hop_command_template.trim(), + hop_vrf: batchHop.hop_vrf.trim(), + }), + onSuccess: async (res) => { + setBatchHopOpen(false); + setBatchHop(emptyHopProxyFields()); + showOk(t("managedNe.hop.batchDone", { n: res.updated })); + await invalidateList(); + }, + onError: (err) => showError(String(err)), + }); + const importMutation = useMutation({ mutationFn: importManagedNe, onSuccess: async (res) => { @@ -318,6 +343,20 @@ export function NePage() { > {connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")} + @@ -565,86 +604,20 @@ export function NePage() { {t("managedNe.hop.enable")} {form.hop_enabled ? ( -
- - - - - - - -
+ setForm((prev) => ({ ...prev, ...patch }))} + hopPasswordRequired={!editing} + hopPasswordOptional={Boolean(editing)} + /> ) : null}
@@ -658,6 +631,42 @@ export function NePage() {
) : null} + + {batchHopOpen ? ( +
setBatchHopOpen(false)}> +
e.stopPropagation()}> +

{t("managedNe.hop.batchTitle")}

+

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

+ setBatchHop((prev) => ({ ...prev, ...patch }))} /> +
+ + +
+
+
+ ) : null} ); } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index f4b3575..3f278c2 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -129,6 +129,20 @@ export const deleteManagedNe = (id: string) => apiDelete<{ ok: boolean }>(`/v1/m export const connectTestManagedNe = (ids: string[]) => apiPost<{ ok: boolean; submitted: number }>("/v1/managed-ne/connect-test", { ids }); +export const batchApplyHopManagedNe = ( + ids: string[], + hop: { + hop_host: string; + hop_port: number; + hop_protocol: string; + hop_username: string; + hop_password: string; + hop_command_template: string; + hop_vrf: string; + hop_vendor?: string; + }, +) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-hop", { ids, hop }); + export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") => `/v1/managed-ne/import/template?format=${format}`;