mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 11:50:44 +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
|
|
@ -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