mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 15:33:18 +08:00
Add topology maps with LLDP discovery and React Flow canvas.
Includes map CRUD, graph save, neighbor discover, and UI controls for labels, sidebar, and edge flow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3b8342acfb
commit
dc805c2086
18 changed files with 3084 additions and 4 deletions
|
|
@ -7,6 +7,7 @@ import { CollectPage } from "./pages/CollectPage";
|
|||
import { NePage } from "./pages/NePage";
|
||||
import { UmePage } from "./pages/UmePage";
|
||||
import { WebcrtPage } from "./pages/WebcrtPage";
|
||||
import { TopologyPage } from "./pages/TopologyPage";
|
||||
import { fetchIntegrationStatus } from "./services/api";
|
||||
|
||||
function App() {
|
||||
|
|
@ -51,6 +52,7 @@ function App() {
|
|||
<Route path="/ne" element={<NePage />} />
|
||||
<Route path="/collect" element={<CollectPage />} />
|
||||
<Route path="/webcrt" element={<WebcrtPage />} />
|
||||
<Route path="/topology" element={<TopologyPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,15 @@ export const MODULES: readonly ModuleDefinition[] = [
|
|||
iconTone: "slate",
|
||||
titleKey: "layout.titleWebcrt",
|
||||
},
|
||||
{
|
||||
moduleId: "topology",
|
||||
path: "/topology",
|
||||
section: "operations",
|
||||
labelKey: "workbench.cards.topology",
|
||||
descKey: "workbench.cards.topologyDesc",
|
||||
iconTone: "blue",
|
||||
titleKey: "layout.titleTopology",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
|
||||
|
|
|
|||
|
|
@ -46,4 +46,6 @@ export const queryKeys = {
|
|||
page: number,
|
||||
pageSize: number,
|
||||
) => ["umeCurrentAlarms", severity, cleared, hostName, keyword, page, pageSize] as const,
|
||||
topologyMaps: ["topologyMaps"] as const,
|
||||
topologyGraph: (mapId: string) => ["topologyGraph", mapId] as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const en = {
|
|||
collectDesc: "Run custom CLI commands on connected NEs and save logs",
|
||||
webcrt: "WebCRT",
|
||||
webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
|
||||
topology: "Topology",
|
||||
topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
|
|
@ -36,6 +38,7 @@ const en = {
|
|||
titleManagedNe: "NE Management",
|
||||
titleCollect: "Batch Collect",
|
||||
titleWebcrt: "WebCRT",
|
||||
titleTopology: "Topology",
|
||||
navUme: "UME",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -588,6 +591,49 @@ const en = {
|
|||
connectFailed: "Device login failed: {{detail}}",
|
||||
},
|
||||
},
|
||||
topology: {
|
||||
title: "Topology",
|
||||
maps: "Maps",
|
||||
newMap: "New",
|
||||
newMapName: "New topology",
|
||||
rename: "Rename",
|
||||
renamePrompt: "Topology map name",
|
||||
renameHint: "Double-click to rename",
|
||||
renamed: "Topology map renamed",
|
||||
deleteMap: "Delete map",
|
||||
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
|
||||
emptyMaps: "No maps yet. Create one to start.",
|
||||
palette: "NE library",
|
||||
paletteHint: "Click to add to canvas",
|
||||
filterPh: "Filter name / IP",
|
||||
save: "Save",
|
||||
saving: "Saving…",
|
||||
saved: "Topology saved",
|
||||
discover: "Discover links",
|
||||
discovering: "Discovering…",
|
||||
discovered: "Discovery done: +{{added}} updated {{updated}}",
|
||||
discoverFail: "Discovery failed: {{detail}}",
|
||||
selectMap: "Select or create a topology map",
|
||||
canvasHint: "Add NEs from the left, drag and connect; dashed edges are LLDP/CDP",
|
||||
selected: "Selected",
|
||||
openWebcrt: "Open terminal",
|
||||
openNe: "NE details",
|
||||
removeNode: "Remove from canvas",
|
||||
noNeLink: "Not linked to a managed NE",
|
||||
edgeManual: "Manual",
|
||||
edgeDiscovered: "Discovered",
|
||||
fit: "Fit view",
|
||||
display: "Display",
|
||||
hideIp: "Hide IP",
|
||||
hideVendor: "Hide vendor",
|
||||
hidePorts: "Hide ports",
|
||||
edgeFlow: "Edge flow",
|
||||
collapseSidebar: "Collapse sidebar",
|
||||
expandSidebar: "Expand sidebar",
|
||||
hideAdded: "Hide added",
|
||||
paletteEmpty: "No matching NEs.",
|
||||
paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.",
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const zh = {
|
|||
collectDesc: "对已连通网元执行定制 CLI 命令并保存日志",
|
||||
webcrt: "WebCRT",
|
||||
webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
|
||||
topology: "拓扑管理",
|
||||
topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
|
|
@ -36,6 +38,7 @@ const zh = {
|
|||
titleManagedNe: "网元管理",
|
||||
titleCollect: "批量采集",
|
||||
titleWebcrt: "WebCRT",
|
||||
titleTopology: "拓扑管理",
|
||||
navUme: "UME 对接",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -586,6 +589,49 @@ const zh = {
|
|||
connectFailed: "登录设备失败:{{detail}}",
|
||||
},
|
||||
},
|
||||
topology: {
|
||||
title: "拓扑管理",
|
||||
maps: "拓扑图",
|
||||
newMap: "新建",
|
||||
newMapName: "新拓扑图",
|
||||
rename: "重命名",
|
||||
renamePrompt: "拓扑图名称",
|
||||
renameHint: "双击可重命名",
|
||||
renamed: "拓扑图已重命名",
|
||||
deleteMap: "删除图",
|
||||
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
|
||||
emptyMaps: "还没有拓扑图,先新建一张。",
|
||||
palette: "网元库",
|
||||
paletteHint: "点击添加到画布",
|
||||
filterPh: "筛选名称 / IP",
|
||||
save: "保存",
|
||||
saving: "保存中…",
|
||||
saved: "拓扑已保存",
|
||||
discover: "发现链路",
|
||||
discovering: "发现中…",
|
||||
discovered: "发现完成:新增 {{added}},更新 {{updated}}",
|
||||
discoverFail: "发现失败:{{detail}}",
|
||||
selectMap: "请选择或新建一张拓扑图",
|
||||
canvasHint: "从左侧添加网元,拖动节点并连线;虚线为 LLDP/CDP 发现链路",
|
||||
selected: "已选节点",
|
||||
openWebcrt: "打开终端",
|
||||
openNe: "网元详情",
|
||||
removeNode: "从画布移除",
|
||||
noNeLink: "未关联托管网元",
|
||||
edgeManual: "人工",
|
||||
edgeDiscovered: "发现",
|
||||
fit: "适应画布",
|
||||
display: "显示选项",
|
||||
hideIp: "隐藏 IP",
|
||||
hideVendor: "隐藏厂商",
|
||||
hidePorts: "隐藏端口",
|
||||
edgeFlow: "链路流动",
|
||||
collapseSidebar: "折叠侧栏",
|
||||
expandSidebar: "展开侧栏",
|
||||
hideAdded: "隐藏已添加",
|
||||
paletteEmpty: "没有匹配的网元。",
|
||||
paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。",
|
||||
},
|
||||
};
|
||||
|
||||
export default zh;
|
||||
|
|
|
|||
|
|
@ -1836,3 +1836,380 @@ pre {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Topology ---- */
|
||||
.topo-page {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 12px;
|
||||
height: calc(100vh - 72px);
|
||||
min-height: 520px;
|
||||
padding: 12px 16px 16px;
|
||||
transition: grid-template-columns 0.18s ease;
|
||||
}
|
||||
|
||||
.topo-page.is-sidebar-collapsed {
|
||||
grid-template-columns: 40px 1fr;
|
||||
}
|
||||
|
||||
.topo-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topo-page.is-sidebar-collapsed .topo-sidebar {
|
||||
padding: 6px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.topo-sidebar__rail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.topo-sidebar__rail:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.topo-sidebar__rail-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topo-sidebar__rail-label {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.topo-sidebar__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topo-sidebar__section--grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topo-sidebar__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topo-sidebar__head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.topo-map-list,
|
||||
.topo-palette {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topo-map-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.topo-map-list li.is-active {
|
||||
background: #e8f1fb;
|
||||
}
|
||||
|
||||
.topo-map-list__item,
|
||||
.topo-palette__item {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.topo-map-list__item:hover,
|
||||
.topo-palette__item:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.topo-palette__item:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.topo-palette__empty {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.topo-map-list__name,
|
||||
.topo-palette__name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.topo-map-list__meta,
|
||||
.topo-palette__meta {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.topo-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topo-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.topo-toolbar__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topo-toolbar__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topo-toolbar__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topo-display-toggles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.topo-display-toggles__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topo-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.topo-selection__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topo-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topo-canvas__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.topo-node {
|
||||
min-width: 72px;
|
||||
max-width: 160px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
--topo-vendor: #64748b;
|
||||
}
|
||||
|
||||
.topo-node--cisco { --topo-vendor: #1d4ed8; }
|
||||
.topo-node--huawei { --topo-vendor: #c62828; }
|
||||
.topo-node--zte { --topo-vendor: #0277bd; }
|
||||
.topo-node--juniper { --topo-vendor: #2e7d32; }
|
||||
.topo-node--nokia { --topo-vendor: #00838f; }
|
||||
.topo-node--ericsson { --topo-vendor: #f9a825; }
|
||||
.topo-node--h3c { --topo-vendor: #ef6c00; }
|
||||
.topo-node--other { --topo-vendor: #546e7a; }
|
||||
|
||||
.topo-node__glyph {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topo-node__handle {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
min-width: 12px !important;
|
||||
min-height: 12px !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.topo-node__handle--center {
|
||||
left: 50% !important;
|
||||
top: 50% !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
}
|
||||
|
||||
.topo-node.is-selected .topo-node__icon-body {
|
||||
stroke: #0f172a;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
.topo-node.is-selected .topo-node__caption {
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topo-node__icon {
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topo-node__icon-shadow {
|
||||
fill: rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.topo-node__icon-rim {
|
||||
fill: color-mix(in srgb, var(--topo-vendor, #64748b) 70%, #0f172a);
|
||||
}
|
||||
|
||||
.topo-node__icon-body {
|
||||
fill: var(--topo-vendor, #64748b);
|
||||
stroke: color-mix(in srgb, var(--topo-vendor, #64748b) 45%, #0f172a);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.topo-node__icon-face {
|
||||
fill: color-mix(in srgb, var(--topo-vendor, #64748b) 62%, #cbd5e1);
|
||||
}
|
||||
|
||||
.topo-node__icon-arrows {
|
||||
fill: #ffffff;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
/* Same plain-text style as edge port labels */
|
||||
.topo-node__caption {
|
||||
margin-top: 2px;
|
||||
color: #475569;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.react-flow__edge-textbg {
|
||||
fill: #ffffff;
|
||||
}
|
||||
|
||||
.react-flow__edge-text {
|
||||
fill: #475569;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.topo-page,
|
||||
.topo-page.is-sidebar-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.topo-sidebar {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.topo-page.is-sidebar-collapsed .topo-sidebar {
|
||||
max-height: none;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.topo-sidebar__rail {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.topo-sidebar__rail-label {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
.topo-canvas {
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
718
web/src/pages/TopologyPage.tsx
Normal file
718
web/src/pages/TopologyPage.tsx
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
Handle,
|
||||
Position,
|
||||
MarkerType,
|
||||
ConnectionMode,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
type ReactFlowInstance,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
createTopologyMap,
|
||||
deleteTopologyMap,
|
||||
discoverTopologyNeighbors,
|
||||
fetchManagedNe,
|
||||
fetchTopologyGraph,
|
||||
fetchTopologyMaps,
|
||||
putTopologyGraph,
|
||||
updateTopologyMap,
|
||||
} from "../services/api";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows";
|
||||
import type { ManagedNeItem, TopologyEdgeItem, TopologyNodeItem } from "../types";
|
||||
|
||||
type NeNodeData = {
|
||||
label: string;
|
||||
managed_ne_id: string;
|
||||
ume_ne_id: string;
|
||||
ne_ip: string;
|
||||
vendor: string;
|
||||
connect_status: string;
|
||||
};
|
||||
|
||||
type TopoDisplayOpts = {
|
||||
hideIp: boolean;
|
||||
hideVendor: boolean;
|
||||
hidePorts: boolean;
|
||||
};
|
||||
|
||||
const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||
hideIp: true,
|
||||
hideVendor: true,
|
||||
hidePorts: true,
|
||||
});
|
||||
|
||||
function newId(): string {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function vendorTone(vendor: string): string {
|
||||
const v = String(vendor || "").trim().toLowerCase();
|
||||
if (v.includes("cisco")) return "cisco";
|
||||
if (v.includes("huawei")) return "huawei";
|
||||
if (v.includes("zte")) return "zte";
|
||||
if (v.includes("juniper")) return "juniper";
|
||||
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
|
||||
if (v.includes("ericsson")) return "ericsson";
|
||||
if (v.includes("h3c") || v.includes("comware")) return "h3c";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Classic Visio/Packet Tracer style router: disc + four outbound arrows. */
|
||||
function RouterIcon() {
|
||||
return (
|
||||
<svg className="topo-node__icon" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<ellipse cx="32" cy="39" rx="19" ry="8.5" className="topo-node__icon-shadow" />
|
||||
<path
|
||||
d="M13 31 A19 9.5 0 0 0 51 31 L51 36.5 A19 9.5 0 0 1 13 36.5 Z"
|
||||
className="topo-node__icon-rim"
|
||||
/>
|
||||
<ellipse cx="32" cy="31" rx="19" ry="9.5" className="topo-node__icon-body" />
|
||||
<ellipse cx="32" cy="30" rx="15.5" ry="7" className="topo-node__icon-face" />
|
||||
<g className="topo-node__icon-arrows">
|
||||
{/* NW / NE / SW / SE */}
|
||||
<path d="M31 29 L19 18 L24.5 18.8 L23.2 23.8 Z" />
|
||||
<path d="M33 29 L45 18 L39.5 18.8 L40.8 23.8 Z" />
|
||||
<path d="M31 31 L19 42 L24.5 41.2 L23.2 36.2 Z" />
|
||||
<path d="M33 31 L45 42 L39.5 41.2 L40.8 36.2 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||
const { hideIp, hideVendor } = useContext(TopoDisplayContext);
|
||||
const tone = vendorTone(data.vendor);
|
||||
const name = data.label || (!hideIp ? data.ne_ip : "") || "NE";
|
||||
const bits = [
|
||||
name,
|
||||
hideIp ? "" : data.ne_ip,
|
||||
hideVendor ? "" : data.vendor,
|
||||
].filter((x, i, arr) => {
|
||||
const s = String(x || "").trim();
|
||||
if (!s) return false;
|
||||
return arr.findIndex((y) => String(y || "").trim() === s) === i;
|
||||
});
|
||||
return (
|
||||
<div className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}`}>
|
||||
<div className="topo-node__glyph">
|
||||
{/* Centered on the disc so edges meet the circular icon, not the caption. */}
|
||||
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--center" />
|
||||
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--center" />
|
||||
<RouterIcon />
|
||||
</div>
|
||||
<div className="topo-node__caption">{bits.join(" · ")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { neNode: NeNode };
|
||||
|
||||
function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) {
|
||||
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "neNode",
|
||||
position: { x: n.x || 0, y: n.y || 0 },
|
||||
data: {
|
||||
label: n.label || n.ne_name || n.ne_ip || n.id,
|
||||
managed_ne_id: n.managed_ne_id || "",
|
||||
ume_ne_id: n.ume_ne_id || "",
|
||||
ne_ip: n.ne_ip || "",
|
||||
vendor: n.vendor || "",
|
||||
connect_status: n.connect_status || "",
|
||||
},
|
||||
}));
|
||||
const rfEdges: Edge[] = edges.map((e) => {
|
||||
const discovered = e.source === "lldp" || e.source === "cdp";
|
||||
const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ ");
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source_node_id,
|
||||
target: e.target_node_id,
|
||||
type: "straight",
|
||||
label: label || undefined,
|
||||
animated: false,
|
||||
style: discovered
|
||||
? { stroke: "#0ea5e9", strokeDasharray: "6 4" }
|
||||
: { stroke: "#64748b" },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
|
||||
data: {
|
||||
source: e.source || "manual",
|
||||
source_port: e.source_port || "",
|
||||
target_port: e.target_port || "",
|
||||
},
|
||||
};
|
||||
});
|
||||
return { rfNodes, rfEdges };
|
||||
}
|
||||
|
||||
function flowToGraphPayload(nodes: Node<NeNodeData>[], edges: Edge[]) {
|
||||
return {
|
||||
nodes: nodes.map((n) => ({
|
||||
id: n.id,
|
||||
managed_ne_id: n.data.managed_ne_id || "",
|
||||
ume_ne_id: n.data.ume_ne_id || "",
|
||||
label: n.data.label || "",
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
})),
|
||||
edges: edges.map((e) => ({
|
||||
id: e.id,
|
||||
source_node_id: e.source,
|
||||
target_node_id: e.target,
|
||||
source_port: String((e.data as { source_port?: string } | undefined)?.source_port || ""),
|
||||
target_port: String((e.data as { target_port?: string } | undefined)?.target_port || ""),
|
||||
source: String((e.data as { source?: string } | undefined)?.source || "manual"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function TopologyPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [mapId, setMapId] = useState<string>("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [hideIp, setHideIp] = useState(true);
|
||||
const [hideVendor, setHideVendor] = useState(true);
|
||||
const [hidePorts, setHidePorts] = useState(true);
|
||||
const [edgeFlow, setEdgeFlow] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [hideAddedNes, setHideAddedNes] = useState(true);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
const displayOpts = useMemo(
|
||||
() => ({ hideIp, hideVendor, hidePorts }),
|
||||
[hideIp, hideVendor, hidePorts],
|
||||
);
|
||||
const displayEdges = useMemo(
|
||||
() =>
|
||||
edges.map((e) => ({
|
||||
...e,
|
||||
label: hidePorts ? undefined : e.label,
|
||||
animated: edgeFlow,
|
||||
})),
|
||||
[edges, hidePorts, edgeFlow],
|
||||
);
|
||||
|
||||
const mapsQuery = useQuery({
|
||||
queryKey: queryKeys.topologyMaps,
|
||||
queryFn: fetchTopologyMaps,
|
||||
});
|
||||
|
||||
const graphQuery = useQuery({
|
||||
queryKey: queryKeys.topologyGraph(mapId),
|
||||
queryFn: () => fetchTopologyGraph(mapId),
|
||||
enabled: Boolean(mapId),
|
||||
});
|
||||
|
||||
const neQuery = useQuery({
|
||||
queryKey: [...queryKeys.managedNeAll, "topology-palette", keyword],
|
||||
queryFn: () =>
|
||||
fetchManagedNe({
|
||||
keyword: keyword.trim(),
|
||||
vendor: "",
|
||||
connectStatus: "",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapId && mapsQuery.data?.items?.length) {
|
||||
setMapId(mapsQuery.data.items[0].id);
|
||||
}
|
||||
}, [mapId, mapsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!graphQuery.data) return;
|
||||
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges);
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
dirtyRef.current = false;
|
||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||
}, [graphQuery.data, setNodes, setEdges]);
|
||||
|
||||
const renameMapMut = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||
updateTopologyMap(id, { name }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
showOk(t("topology.renamed"));
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const promptRenameMap = useCallback(
|
||||
(id: string, currentName: string) => {
|
||||
const next = window.prompt(t("topology.renamePrompt"), currentName);
|
||||
if (next == null) return;
|
||||
const name = next.trim();
|
||||
if (!name || name === currentName) return;
|
||||
renameMapMut.mutate({ id, name });
|
||||
},
|
||||
[renameMapMut, t],
|
||||
);
|
||||
|
||||
const createMapMut = useMutation({
|
||||
mutationFn: () => createTopologyMap({ name: t("topology.newMapName") }),
|
||||
onSuccess: async (row) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
setMapId(row.id);
|
||||
showOk(t("topology.newMap"));
|
||||
// Prompt rename right after create — default name is a placeholder.
|
||||
window.setTimeout(() => promptRenameMap(row.id, row.name), 0);
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const deleteMapMut = useMutation({
|
||||
mutationFn: (id: string) => deleteTopologyMap(id),
|
||||
onSuccess: async (_out, id) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
if (mapId === id) {
|
||||
setMapId("");
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
}
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)),
|
||||
onSuccess: async (graph) => {
|
||||
dirtyRef.current = false;
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
showOk(t("topology.saved"));
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const discoverMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (dirtyRef.current) {
|
||||
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
|
||||
}
|
||||
return discoverTopologyNeighbors(mapId, { protocol: "auto" });
|
||||
},
|
||||
onSuccess: async (out) => {
|
||||
if (out.graph) {
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
|
||||
const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges);
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
dirtyRef.current = false;
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
showOk(
|
||||
t("topology.discovered")
|
||||
.replace("{{added}}", String(out.edges_added))
|
||||
.replace("{{updated}}", String(out.edges_updated)),
|
||||
);
|
||||
},
|
||||
onError: (err) =>
|
||||
showError(t("topology.discoverFail").replace("{{detail}}", String(err))),
|
||||
});
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
dirtyRef.current = true;
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
{
|
||||
...connection,
|
||||
id: newId(),
|
||||
type: "straight",
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
|
||||
style: { stroke: "#64748b" },
|
||||
data: { source: "manual", source_port: "", target_port: "" },
|
||||
},
|
||||
eds,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setEdges],
|
||||
);
|
||||
|
||||
const addNeToCanvas = (ne: ManagedNeItem) => {
|
||||
if (!mapId) {
|
||||
showError(t("topology.selectMap"));
|
||||
return;
|
||||
}
|
||||
if (nodes.some((n) => n.data.managed_ne_id === ne.id)) {
|
||||
return;
|
||||
}
|
||||
const offset = nodes.length * 24;
|
||||
dirtyRef.current = true;
|
||||
setNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newId(),
|
||||
type: "neNode",
|
||||
position: { x: 80 + offset, y: 80 + offset },
|
||||
data: {
|
||||
label: ne.name || ne.ip_address,
|
||||
managed_ne_id: ne.id,
|
||||
ume_ne_id: "",
|
||||
ne_ip: ne.ip_address,
|
||||
vendor: ne.vendor,
|
||||
connect_status: ne.connect_status,
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find((n) => n.id === selectedNodeId) || null,
|
||||
[nodes, selectedNodeId],
|
||||
);
|
||||
|
||||
const removeSelected = () => {
|
||||
if (!selectedNodeId) return;
|
||||
dirtyRef.current = true;
|
||||
setNodes((ns) => ns.filter((n) => n.id !== selectedNodeId));
|
||||
setEdges((es) => es.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId));
|
||||
setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
const openWebcrt = () => {
|
||||
const neId = selectedNode?.data.managed_ne_id;
|
||||
if (!neId) {
|
||||
showError(t("topology.noNeLink"));
|
||||
return;
|
||||
}
|
||||
openNewModuleWindow({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(neId)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const openNe = () => {
|
||||
const neId = selectedNode?.data.managed_ne_id;
|
||||
if (!neId) {
|
||||
showError(t("topology.noNeLink"));
|
||||
return;
|
||||
}
|
||||
openOrFocusModule({
|
||||
moduleId: "managed-ne",
|
||||
path: "/ne",
|
||||
});
|
||||
};
|
||||
|
||||
const maps = mapsQuery.data?.items || [];
|
||||
const palette = neQuery.data?.items || [];
|
||||
const onCanvasIds = useMemo(
|
||||
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
|
||||
[nodes],
|
||||
);
|
||||
const paletteVisible = useMemo(
|
||||
() => (hideAddedNes ? palette.filter((ne) => !onCanvasIds.has(ne.id)) : palette),
|
||||
[palette, hideAddedNes, onCanvasIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
|
||||
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="topo-sidebar__rail"
|
||||
title={t("topology.expandSidebar")}
|
||||
aria-label={t("topology.expandSidebar")}
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
>
|
||||
<span className="topo-sidebar__rail-icon" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
<span className="topo-sidebar__rail-label">{t("topology.maps")}</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="topo-sidebar__section">
|
||||
<div className="topo-sidebar__head">
|
||||
<strong>{t("topology.maps")}</strong>
|
||||
<div className="topo-sidebar__head-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
onClick={() => createMapMut.mutate()}
|
||||
disabled={createMapMut.isPending}
|
||||
>
|
||||
{t("topology.newMap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
title={t("topology.collapseSidebar")}
|
||||
aria-label={t("topology.collapseSidebar")}
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{maps.length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.emptyMaps")}</p>
|
||||
) : (
|
||||
<ul className="topo-map-list">
|
||||
{maps.map((m) => (
|
||||
<li key={m.id} className={mapId === m.id ? "is-active" : ""}>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-map-list__item"
|
||||
onClick={() => setMapId(m.id)}
|
||||
onDoubleClick={() => promptRenameMap(m.id, m.name)}
|
||||
title={t("topology.renameHint")}
|
||||
>
|
||||
<span className="topo-map-list__name">{m.name}</span>
|
||||
<span className="topo-map-list__meta">
|
||||
{m.node_count}N / {m.edge_count}E
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
title={t("topology.rename")}
|
||||
disabled={renameMapMut.isPending}
|
||||
onClick={() => promptRenameMap(m.id, m.name)}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
title={t("topology.deleteMap")}
|
||||
onClick={() => {
|
||||
const msg = t("topology.deleteMapConfirm").replace("{{name}}", m.name);
|
||||
if (window.confirm(msg)) deleteMapMut.mutate(m.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="topo-sidebar__section topo-sidebar__section--grow">
|
||||
<div className="topo-sidebar__head">
|
||||
<strong>{t("topology.palette")}</strong>
|
||||
<label className="topo-display-toggles__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideAddedNes}
|
||||
onChange={(e) => setHideAddedNes(e.target.checked)}
|
||||
/>
|
||||
{t("topology.hideAdded")}
|
||||
</label>
|
||||
</div>
|
||||
<p className="panel__hint">{t("topology.paletteHint")}</p>
|
||||
<input
|
||||
className="input"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder={t("topology.filterPh")}
|
||||
/>
|
||||
<ul className="topo-palette">
|
||||
{paletteVisible.length === 0 ? (
|
||||
<li className="topo-palette__empty">
|
||||
<span className="panel__hint">
|
||||
{palette.length === 0
|
||||
? t("topology.paletteEmpty")
|
||||
: t("topology.paletteHiddenAll")}
|
||||
</span>
|
||||
</li>
|
||||
) : (
|
||||
paletteVisible.map((ne) => {
|
||||
const onCanvas = onCanvasIds.has(ne.id);
|
||||
return (
|
||||
<li key={ne.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-palette__item"
|
||||
disabled={!mapId || onCanvas}
|
||||
onClick={() => addNeToCanvas(ne)}
|
||||
title={`${ne.name}\n${ne.ip_address}`}
|
||||
>
|
||||
<span className="topo-palette__name">{ne.name || ne.ip_address}</span>
|
||||
<span className="topo-palette__meta">
|
||||
{ne.ip_address} · {ne.vendor}
|
||||
{onCanvas ? " ✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="topo-main">
|
||||
<div className="topo-toolbar">
|
||||
<div className="topo-toolbar__left">
|
||||
<div className="topo-toolbar__title">
|
||||
<strong>{maps.find((m) => m.id === mapId)?.name || t("topology.selectMap")}</strong>
|
||||
{mapId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
title={t("topology.rename")}
|
||||
disabled={renameMapMut.isPending}
|
||||
onClick={() => {
|
||||
const current = maps.find((m) => m.id === mapId)?.name || "";
|
||||
promptRenameMap(mapId, current);
|
||||
}}
|
||||
>
|
||||
{t("topology.rename")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="panel__hint">{t("topology.canvasHint")}</span>
|
||||
</div>
|
||||
<div className="topo-toolbar__actions">
|
||||
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
|
||||
<label className="topo-display-toggles__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideIp}
|
||||
onChange={(e) => setHideIp(e.target.checked)}
|
||||
/>
|
||||
{t("topology.hideIp")}
|
||||
</label>
|
||||
<label className="topo-display-toggles__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideVendor}
|
||||
onChange={(e) => setHideVendor(e.target.checked)}
|
||||
/>
|
||||
{t("topology.hideVendor")}
|
||||
</label>
|
||||
<label className="topo-display-toggles__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hidePorts}
|
||||
onChange={(e) => setHidePorts(e.target.checked)}
|
||||
/>
|
||||
{t("topology.hidePorts")}
|
||||
</label>
|
||||
<label className="topo-display-toggles__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={edgeFlow}
|
||||
onChange={(e) => setEdgeFlow(e.target.checked)}
|
||||
/>
|
||||
{t("topology.edgeFlow")}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={!mapId || saveMut.isPending}
|
||||
onClick={() => saveMut.mutate()}
|
||||
>
|
||||
{saveMut.isPending ? t("topology.saving") : t("topology.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={!mapId || discoverMut.isPending}
|
||||
onClick={() => discoverMut.mutate()}
|
||||
>
|
||||
{discoverMut.isPending ? t("topology.discovering") : t("topology.discover")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!mapId}
|
||||
onClick={() => rfRef.current?.fitView({ padding: 0.2 })}
|
||||
>
|
||||
{t("topology.fit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNode ? (
|
||||
<div className="topo-selection">
|
||||
<span>
|
||||
{t("topology.selected")}: <strong>{selectedNode.data.label}</strong>
|
||||
{selectedNode.data.ne_ip ? ` (${selectedNode.data.ne_ip})` : ""}
|
||||
</span>
|
||||
<div className="topo-selection__actions">
|
||||
<button type="button" className="btn btn--sm" onClick={openWebcrt}>
|
||||
{t("topology.openWebcrt")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--sm" onClick={openNe}>
|
||||
{t("topology.openNe")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--sm btn--ghost" onClick={removeSelected}>
|
||||
{t("topology.removeNode")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="topo-canvas">
|
||||
{mapId ? (
|
||||
<TopoDisplayContext.Provider value={displayOpts}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={displayEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
defaultEdgeOptions={{ type: "straight" }}
|
||||
onNodesChange={(changes) => {
|
||||
dirtyRef.current = true;
|
||||
onNodesChange(changes);
|
||||
}}
|
||||
onEdgesChange={(changes) => {
|
||||
dirtyRef.current = true;
|
||||
onEdgesChange(changes);
|
||||
}}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={(_e, node) => setSelectedNodeId(node.id)}
|
||||
onPaneClick={() => setSelectedNodeId(null)}
|
||||
onInit={(inst) => {
|
||||
rfRef.current = inst;
|
||||
}}
|
||||
fitView
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
>
|
||||
<Background gap={18} size={1} />
|
||||
<Controls />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
</TopoDisplayContext.Provider>
|
||||
) : (
|
||||
<div className="topo-canvas__empty">{t("topology.selectMap")}</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,9 @@ import type {
|
|||
CliMeta,
|
||||
CliTargetListResponse,
|
||||
UmeCliOverrideItem,
|
||||
TopologyDiscoverOut,
|
||||
TopologyGraph,
|
||||
TopologyMapItem,
|
||||
} from "../types";
|
||||
|
||||
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
|
||||
|
|
@ -66,6 +69,17 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
|
|||
return data as T;
|
||||
};
|
||||
|
||||
export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await parseApiResponse(res);
|
||||
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||
return data as T;
|
||||
};
|
||||
|
||||
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
|
||||
|
||||
export const fetchUmeAlarmSubscriptionStatus = () =>
|
||||
|
|
@ -411,3 +425,51 @@ export const fetchUmeCurrentAlarms = (params: {
|
|||
`/v1/ume/alarms?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchTopologyMaps = () =>
|
||||
apiGet<{ total: number; items: TopologyMapItem[] }>("/v1/topology/maps");
|
||||
|
||||
export const createTopologyMap = (body: { name: string; remark?: string }) =>
|
||||
apiPost<TopologyMapItem>("/v1/topology/maps", body);
|
||||
|
||||
export const fetchTopologyGraph = (mapId: string) =>
|
||||
apiGet<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}`);
|
||||
|
||||
export const updateTopologyMap = (mapId: string, body: { name?: string; remark?: string }) =>
|
||||
apiPatch<TopologyMapItem>(`/v1/topology/maps/${encodeURIComponent(mapId)}`, body);
|
||||
|
||||
export const deleteTopologyMap = (mapId: string) =>
|
||||
apiDelete<{ ok: boolean; map_id: string; deleted: boolean }>(
|
||||
`/v1/topology/maps/${encodeURIComponent(mapId)}`,
|
||||
);
|
||||
|
||||
export const putTopologyGraph = (
|
||||
mapId: string,
|
||||
body: {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
managed_ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
label?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}>;
|
||||
edges: Array<{
|
||||
id: string;
|
||||
source_node_id: string;
|
||||
target_node_id: string;
|
||||
source_port?: string;
|
||||
target_port?: string;
|
||||
source?: string;
|
||||
}>;
|
||||
},
|
||||
) => apiPut<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body);
|
||||
|
||||
export const discoverTopologyNeighbors = (
|
||||
mapId: string,
|
||||
body?: { protocol?: string; ne_ids?: string[] },
|
||||
) =>
|
||||
apiPost<TopologyDiscoverOut>(
|
||||
`/v1/topology/maps/${encodeURIComponent(mapId)}/discover`,
|
||||
body || {},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -343,3 +343,68 @@ export type UmeTokenStatus = {
|
|||
error_kind?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type TopologyMapItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
remark: string;
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TopologyNodeItem = {
|
||||
id: string;
|
||||
map_id: string;
|
||||
managed_ne_id: string;
|
||||
ume_ne_id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
ne_name: string;
|
||||
ne_ip: string;
|
||||
vendor: string;
|
||||
protocol: string;
|
||||
connect_status: string;
|
||||
};
|
||||
|
||||
export type TopologyEdgeItem = {
|
||||
id: string;
|
||||
map_id: string;
|
||||
source_node_id: string;
|
||||
target_node_id: string;
|
||||
source_port: string;
|
||||
target_port: string;
|
||||
source: string;
|
||||
discovered_at?: string | null;
|
||||
};
|
||||
|
||||
export type TopologyGraph = {
|
||||
map: TopologyMapItem;
|
||||
nodes: TopologyNodeItem[];
|
||||
edges: TopologyEdgeItem[];
|
||||
};
|
||||
|
||||
export type TopologyDiscoverNeResult = {
|
||||
ne_id: string;
|
||||
ne_name: string;
|
||||
ne_ip: string;
|
||||
ok: boolean;
|
||||
command: string;
|
||||
neighbors: number;
|
||||
edges_added: number;
|
||||
edges_updated: number;
|
||||
error: string;
|
||||
raw_preview: string;
|
||||
};
|
||||
|
||||
export type TopologyDiscoverOut = {
|
||||
map_id: string;
|
||||
protocol: string;
|
||||
scanned: number;
|
||||
edges_added: number;
|
||||
edges_updated: number;
|
||||
results: TopologyDiscoverNeResult[];
|
||||
graph: TopologyGraph | null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue