From c4968e334f59dff7c1b7431d254de8af35b70c32 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 4 Aug 2026 15:23:44 +0800 Subject: [PATCH] Route incomplete topology terminals to NE inventory instead of a mini form. Co-authored-by: Cursor --- web/src/i18n/en.ts | 21 +-- web/src/i18n/zh.ts | 21 +-- web/src/pages/NePage.tsx | 70 +++++++ web/src/pages/TopologyPage.tsx | 333 ++++----------------------------- 4 files changed, 113 insertions(+), 332 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 73ae78b..c207191 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 27d8c34..96fa301 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "未发现", diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index 91f4867..d0db255 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -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(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])); }; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 200877d..8cffe25 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -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({ 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(null); - const [termBusy, setTermBusy] = useState(false); const [paletteSource, setPaletteSource] = useState("managed"); const [paletteSelectedKeys, setPaletteSelectedKeys] = useState([]); 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 | 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 | 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() { )} ) : null} - - {termDialog ? ( -
{ - if (!termBusy) setTermDialog(null); - }} - > -
e.stopPropagation()} - > -

- {termDialog.neId ? t("topology.termConnect.title") : t("topology.termConnect.titleCreate")} -

-

{t("topology.termConnect.hint")}

-
- - - - - - {termDialog.protocol === "ssh" ? ( - <> - - - - ) : null} -
-
- - -
-
-
- ) : null} ); }