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

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