Route incomplete topology terminals to NE inventory instead of a mini form.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-04 15:23:44 +08:00
parent 417ed66a53
commit c4968e334f
4 changed files with 113 additions and 332 deletions

View file

@ -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",

View file

@ -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: "未发现",

View file

@ -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]));
};

View file

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