mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Route incomplete topology terminals to NE inventory instead of a mini form.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
417ed66a53
commit
c4968e334f
4 changed files with 113 additions and 332 deletions
|
|
@ -1395,25 +1395,8 @@ const en = {
|
|||
edgeDefaults: "Default edge styles",
|
||||
edgeDefaultsReset: "Factory reset",
|
||||
noNeLink: "Not linked to a managed NE",
|
||||
termConnect: {
|
||||
title: "Complete login",
|
||||
titleCreate: "Create NE and connect",
|
||||
hint: "This node is not managed or lacks credentials. Enter IP, vendor, and protocol to connect (same as creating/completing an NE).",
|
||||
name: "Name",
|
||||
vendor: "Vendor",
|
||||
ip: "IP address",
|
||||
port: "Port",
|
||||
protocol: "Protocol",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
connect: "Save and open terminal",
|
||||
connecting: "Connecting…",
|
||||
cancel: "Cancel",
|
||||
ipRequired: "IP address is required",
|
||||
userRequired: "Username is required for SSH",
|
||||
passwordRequired: "Password is required for SSH",
|
||||
saved: "Login details saved",
|
||||
},
|
||||
completeNeFirst:
|
||||
"Login details are incomplete (vendor, hop/proxy, etc. belong in NE inventory). Complete the NE first, then open the terminal.",
|
||||
edgeManual: "Manual",
|
||||
edgeDiscovered: "Discovered",
|
||||
edgeStale: "Missing",
|
||||
|
|
|
|||
|
|
@ -1389,25 +1389,8 @@ const zh = {
|
|||
edgeDefaults: "默认链路样式",
|
||||
edgeDefaultsReset: "恢复出厂",
|
||||
noNeLink: "未关联托管网元",
|
||||
termConnect: {
|
||||
title: "补全登录信息",
|
||||
titleCreate: "新建网元并登录",
|
||||
hint: "该网元尚未纳管或缺少登录信息,请填写 IP、厂家与协议后连接(等同新建/补全网元)。",
|
||||
name: "名称",
|
||||
vendor: "厂家",
|
||||
ip: "IP 地址",
|
||||
port: "端口",
|
||||
protocol: "协议",
|
||||
username: "用户名",
|
||||
password: "密码",
|
||||
connect: "保存并打开终端",
|
||||
connecting: "连接中…",
|
||||
cancel: "取消",
|
||||
ipRequired: "请填写 IP 地址",
|
||||
userRequired: "SSH 请填写用户名",
|
||||
passwordRequired: "SSH 请填写密码",
|
||||
saved: "登录信息已保存",
|
||||
},
|
||||
completeNeFirst:
|
||||
"该网元登录信息不完整(厂家、代理跳转等需在网元管理填写)。请先完善后再打开终端。",
|
||||
edgeManual: "人工",
|
||||
edgeDiscovered: "发现",
|
||||
edgeStale: "未发现",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
batchApplyAccountManagedNe,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
deleteManagedNe,
|
||||
fetchIdsByTag,
|
||||
fetchManagedNe,
|
||||
fetchManagedNeById,
|
||||
fetchManagedNeMeta,
|
||||
fetchManagedNeStats,
|
||||
importManagedNe,
|
||||
|
|
@ -66,6 +68,15 @@ type AccountState = {
|
|||
password: string;
|
||||
};
|
||||
|
||||
function deviceTypeForVendor(vendor: string): string {
|
||||
if (vendor === "ZTE") return "zte_zxros";
|
||||
if (vendor === "Huawei") return "huawei";
|
||||
if (vendor === "Cisco") return "cisco_ios";
|
||||
if (vendor === "Juniper") return "juniper_junos";
|
||||
if (vendor === "Nokia") return "nokia_sros";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
vendor: "ZTE",
|
||||
|
|
@ -136,6 +147,8 @@ export function NePage() {
|
|||
const canWriteNe = isAdmin || hasScope("ne:write");
|
||||
const queryClient = useQueryClient();
|
||||
const importRef = useRef<HTMLInputElement>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deepLinkHandled = useRef("");
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [vendorFilter, setVendorFilter] = useState("");
|
||||
|
|
@ -466,6 +479,63 @@ export function NePage() {
|
|||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Deep links from topology: /ne?ne_id=… (edit) or /ne?create=1&name=&ip_address=&vendor=
|
||||
useEffect(() => {
|
||||
const neId = String(searchParams.get("ne_id") || "").trim();
|
||||
const wantCreate = searchParams.get("create") === "1";
|
||||
if (!neId && !wantCreate) {
|
||||
deepLinkHandled.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKey = neId ? `edit:${neId}` : `create:${searchParams.toString()}`;
|
||||
if (deepLinkHandled.current === handleKey) return;
|
||||
deepLinkHandled.current = handleKey;
|
||||
|
||||
const clearDeepLink = () => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const key of ["ne_id", "create", "name", "ip_address", "vendor"]) {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
};
|
||||
|
||||
if (neId) {
|
||||
void (async () => {
|
||||
try {
|
||||
const row = await fetchManagedNeById(neId);
|
||||
openEdit(row);
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
} finally {
|
||||
clearDeepLink();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
const name = String(searchParams.get("name") || "").trim();
|
||||
const ip = String(searchParams.get("ip_address") || "").trim();
|
||||
const vendorRaw = String(searchParams.get("vendor") || "").trim();
|
||||
const base = emptyForm();
|
||||
const vendor = vendorRaw || base.vendor;
|
||||
setEditing(null);
|
||||
setForm({
|
||||
...base,
|
||||
name,
|
||||
vendor,
|
||||
device_type: deviceTypeForVendor(vendor),
|
||||
ip_address: ip,
|
||||
});
|
||||
setModalOpen(true);
|
||||
clearDeepLink();
|
||||
}, [searchParams, setSearchParams, showError]);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import "@xyflow/react/dist/style.css";
|
|||
import {
|
||||
addTopologyViewNodes,
|
||||
createFabricManualEdge,
|
||||
createManagedNe,
|
||||
createTopologyFolder,
|
||||
createTopologyView,
|
||||
deleteTopologyFolder,
|
||||
|
|
@ -42,7 +41,6 @@ import {
|
|||
fetchLldpDiscoverJob,
|
||||
fetchManagedNe,
|
||||
fetchManagedNeById,
|
||||
fetchManagedNeMeta,
|
||||
fetchTopologyGraph,
|
||||
fetchTopologyTree,
|
||||
fetchUmeNe,
|
||||
|
|
@ -52,7 +50,6 @@ import {
|
|||
removeTopologyViewNodes,
|
||||
searchFabricNodes,
|
||||
startLldpDiscover,
|
||||
updateManagedNe,
|
||||
updateTopologyFolder,
|
||||
updateTopologyMap,
|
||||
} from "../services/api";
|
||||
|
|
@ -295,18 +292,6 @@ type CtxMenu =
|
|||
| { kind: "edge"; id: string; x: number; y: number }
|
||||
| { kind: "selection"; x: number; y: number };
|
||||
|
||||
/** Complete / create login for unmanaged topology nodes (LLDP placeholders, orphans). */
|
||||
type TermConnectDialog = {
|
||||
neId: string;
|
||||
name: string;
|
||||
vendor: string;
|
||||
ip_address: string;
|
||||
port: number;
|
||||
protocol: "ssh" | "telnet";
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||
hideIp: true,
|
||||
hideVendor: true,
|
||||
|
|
@ -724,8 +709,6 @@ export function TopologyPage() {
|
|||
);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [addNeOpen, setAddNeOpen] = useState(false);
|
||||
const [termDialog, setTermDialog] = useState<TermConnectDialog | null>(null);
|
||||
const [termBusy, setTermBusy] = useState(false);
|
||||
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
||||
const [paletteAdding, setPaletteAdding] = useState(false);
|
||||
|
|
@ -837,16 +820,6 @@ export function TopologyPage() {
|
|||
});
|
||||
}, [edges, expandPhysicalLinks, hidePorts, edgeFlow, edgeDefaults, selectedEdgeId]);
|
||||
|
||||
const neMetaQuery = useQuery({
|
||||
queryKey: queryKeys.managedNeMeta,
|
||||
queryFn: fetchManagedNeMeta,
|
||||
enabled: Boolean(termDialog),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const termVendors = neMetaQuery.data?.vendors?.length
|
||||
? neMetaQuery.data.vendors
|
||||
: ["ZTE", "Huawei", "Cisco", "H3C", "Juniper", "Nokia", "Other"];
|
||||
|
||||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
|
|
@ -1873,44 +1846,53 @@ export function TopologyPage() {
|
|||
closeCtxMenu();
|
||||
};
|
||||
|
||||
const openNeInventory = (opts: {
|
||||
neId?: string;
|
||||
create?: boolean;
|
||||
name?: string;
|
||||
ip_address?: string;
|
||||
vendor?: string;
|
||||
}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.neId) {
|
||||
q.set("ne_id", opts.neId);
|
||||
} else if (opts.create) {
|
||||
q.set("create", "1");
|
||||
if (opts.name) q.set("name", opts.name);
|
||||
if (opts.ip_address) q.set("ip_address", opts.ip_address);
|
||||
if (opts.vendor) q.set("vendor", opts.vendor);
|
||||
}
|
||||
const qs = q.toString();
|
||||
openOrFocusModule({
|
||||
moduleId: "ne",
|
||||
path: qs ? `/ne?${qs}` : "/ne",
|
||||
});
|
||||
};
|
||||
|
||||
const openWebcrtFor = (node: Node<NeNodeData> | null) => {
|
||||
closeCtxMenu();
|
||||
if (!node) return;
|
||||
const managedId = String(node.data.managed_ne_id || "").trim();
|
||||
const umeId = String(node.data.ume_ne_id || "").trim();
|
||||
|
||||
const openManaged = (neId: string) => {
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(neId)}`,
|
||||
});
|
||||
};
|
||||
|
||||
if (managedId) {
|
||||
void (async () => {
|
||||
try {
|
||||
const ne = await fetchManagedNeById(managedId);
|
||||
const src = String(ne.source || "").trim().toLowerCase();
|
||||
const hasIp = Boolean(String(ne.ip_address || "").trim());
|
||||
// Ready inventory / WebCRT hosts: reuse stored credentials.
|
||||
// LLDP placeholders (empty IP) and other incomplete rows need a form.
|
||||
// Ready inventory hosts: reuse stored credentials (incl. hop/proxy).
|
||||
// LLDP placeholders / incomplete rows → full NE edit form (vendor, hop, etc.).
|
||||
const needsSetup = !hasIp || src === "lldp";
|
||||
if (!needsSetup) {
|
||||
openManaged(managedId);
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(managedId)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hintIp = String(ne.ip_address || ne.source_ref || node.data.ne_ip || "").trim();
|
||||
const proto = String(ne.protocol || "ssh").toLowerCase() === "telnet" ? "telnet" : "ssh";
|
||||
setTermDialog({
|
||||
neId: managedId,
|
||||
name: String(ne.name || node.data.label || "").trim(),
|
||||
vendor: String(ne.vendor || node.data.vendor || "Other").trim() || "Other",
|
||||
ip_address: hintIp,
|
||||
port: Number(ne.port) || (proto === "telnet" ? 23 : 22),
|
||||
protocol: proto,
|
||||
username: String(ne.username || "").trim(),
|
||||
password: "",
|
||||
});
|
||||
showOk(t("topology.completeNeFirst"));
|
||||
openNeInventory({ neId: managedId });
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
|
|
@ -1926,103 +1908,22 @@ export function TopologyPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
// No inventory link — same as creating a managed NE, then open terminal.
|
||||
setTermDialog({
|
||||
neId: "",
|
||||
// No inventory link — open NE create with topology hint fields.
|
||||
showOk(t("topology.completeNeFirst"));
|
||||
openNeInventory({
|
||||
create: true,
|
||||
name: String(node.data.label || "").trim(),
|
||||
vendor: String(node.data.vendor || "Other").trim() || "Other",
|
||||
ip_address: String(node.data.ne_ip || "").trim(),
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
username: "",
|
||||
password: "",
|
||||
vendor: String(node.data.vendor || "").trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const submitTermDialog = async () => {
|
||||
if (!termDialog || termBusy) return;
|
||||
const ip = termDialog.ip_address.trim();
|
||||
if (!ip) {
|
||||
showError(t("topology.termConnect.ipRequired"));
|
||||
return;
|
||||
}
|
||||
const protocol = termDialog.protocol;
|
||||
const username = termDialog.username.trim();
|
||||
if (protocol === "ssh" && !username) {
|
||||
showError(t("topology.termConnect.userRequired"));
|
||||
return;
|
||||
}
|
||||
if (protocol === "ssh" && !termDialog.password) {
|
||||
showError(t("topology.termConnect.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
const port = Number(termDialog.port) || (protocol === "telnet" ? 23 : 22);
|
||||
const name = termDialog.name.trim() || ip;
|
||||
const vendor = String(termDialog.vendor || "Other").trim() || "Other";
|
||||
setTermBusy(true);
|
||||
try {
|
||||
let neId = termDialog.neId;
|
||||
if (neId) {
|
||||
await updateManagedNe(neId, {
|
||||
name,
|
||||
vendor,
|
||||
ip_address: ip,
|
||||
port,
|
||||
protocol,
|
||||
username,
|
||||
...(protocol === "ssh" ? { password: termDialog.password } : {}),
|
||||
});
|
||||
setNodes((ns) =>
|
||||
ns.map((n) =>
|
||||
String(n.data.managed_ne_id || "") === neId
|
||||
? {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
ne_ip: ip,
|
||||
vendor,
|
||||
label: name || n.data.label,
|
||||
},
|
||||
}
|
||||
: n,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const created = await createManagedNe({
|
||||
name,
|
||||
vendor,
|
||||
device_type: "generic",
|
||||
ip_address: ip,
|
||||
port,
|
||||
protocol,
|
||||
username: username || (protocol === "telnet" ? "" : username),
|
||||
password: protocol === "ssh" ? termDialog.password : "",
|
||||
});
|
||||
neId = created.id;
|
||||
}
|
||||
setTermDialog(null);
|
||||
showOk(t("topology.termConnect.saved"));
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(neId)}`,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
} finally {
|
||||
setTermBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openNeFor = (node: Node<NeNodeData> | null) => {
|
||||
closeCtxMenu();
|
||||
const managedId = node?.data.managed_ne_id;
|
||||
const umeId = node?.data.ume_ne_id;
|
||||
const managedId = String(node?.data.managed_ne_id || "").trim();
|
||||
const umeId = String(node?.data.ume_ne_id || "").trim();
|
||||
if (managedId) {
|
||||
openOrFocusModule({
|
||||
moduleId: "ne",
|
||||
path: "/ne",
|
||||
});
|
||||
openNeInventory({ neId: managedId });
|
||||
return;
|
||||
}
|
||||
if (umeId) {
|
||||
|
|
@ -3904,162 +3805,6 @@ export function TopologyPage() {
|
|||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{termDialog ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => {
|
||||
if (!termBusy) setTermDialog(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="topo-term-connect-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="topo-term-connect-title">
|
||||
{termDialog.neId ? t("topology.termConnect.title") : t("topology.termConnect.titleCreate")}
|
||||
</h3>
|
||||
<p className="form-hint">{t("topology.termConnect.hint")}</p>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="form-label">{t("topology.termConnect.name")}</span>
|
||||
<input
|
||||
value={termDialog.name}
|
||||
disabled={termBusy}
|
||||
onChange={(e) => setTermDialog({ ...termDialog, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">
|
||||
{t("topology.termConnect.vendor")}
|
||||
<span className="form-label__required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
value={
|
||||
termVendors.includes(termDialog.vendor) ? termDialog.vendor : termDialog.vendor || "Other"
|
||||
}
|
||||
disabled={termBusy}
|
||||
onChange={(e) => setTermDialog({ ...termDialog, vendor: e.target.value })}
|
||||
>
|
||||
{!termVendors.includes(termDialog.vendor) && termDialog.vendor ? (
|
||||
<option value={termDialog.vendor}>{termDialog.vendor}</option>
|
||||
) : null}
|
||||
{termVendors.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">
|
||||
{t("topology.termConnect.protocol")}
|
||||
<span className="form-label__required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
value={termDialog.protocol}
|
||||
disabled={termBusy}
|
||||
onChange={(e) => {
|
||||
const protocol = e.target.value === "telnet" ? "telnet" : "ssh";
|
||||
setTermDialog({
|
||||
...termDialog,
|
||||
protocol,
|
||||
port: protocol === "telnet" ? 23 : 22,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="ssh">SSH</option>
|
||||
<option value="telnet">Telnet</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">
|
||||
{t("topology.termConnect.ip")}
|
||||
<span className="form-label__required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
value={termDialog.ip_address}
|
||||
disabled={termBusy}
|
||||
placeholder="192.168.1.1"
|
||||
onChange={(e) => setTermDialog({ ...termDialog, ip_address: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">{t("topology.termConnect.port")}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={termDialog.port}
|
||||
disabled={termBusy}
|
||||
onChange={(e) =>
|
||||
setTermDialog({
|
||||
...termDialog,
|
||||
port: Number(e.target.value) || (termDialog.protocol === "telnet" ? 23 : 22),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{termDialog.protocol === "ssh" ? (
|
||||
<>
|
||||
<label>
|
||||
<span className="form-label">
|
||||
{t("topology.termConnect.username")}
|
||||
<span className="form-label__required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
required
|
||||
value={termDialog.username}
|
||||
disabled={termBusy}
|
||||
onChange={(e) => setTermDialog({ ...termDialog, username: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">
|
||||
{t("topology.termConnect.password")}
|
||||
<span className="form-label__required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={termDialog.password}
|
||||
disabled={termBusy}
|
||||
onChange={(e) => setTermDialog({ ...termDialog, password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="modal__actions">
|
||||
<button type="button" disabled={termBusy} onClick={() => setTermDialog(null)}>
|
||||
{t("topology.termConnect.cancel")}
|
||||
</button>
|
||||
<button type="button" disabled={termBusy} onClick={() => void submitTermDialog()}>
|
||||
{termBusy ? t("topology.termConnect.connecting") : t("topology.termConnect.connect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue