mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Add LLDP collect page with conservative fabric edge lifecycle.
Move scheduled discovery under Network → LLDP links, mark absent edges missing after one successful scan, purge only after four miss cycles, and expose unmatched/raw job detail. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9674de221d
commit
d05ba0f1f7
30 changed files with 4713 additions and 1870 deletions
|
|
@ -13,6 +13,7 @@ import { NetworkLayout } from "./pages/network/NetworkLayout";
|
|||
import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage";
|
||||
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
|
||||
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
|
||||
import { LldpLinksPage } from "./pages/network/LldpLinksPage";
|
||||
import { PortTrafficPage } from "./pages/network/PortTrafficPage";
|
||||
import { PortTrafficBoardListPage } from "./pages/network/PortTrafficBoardListPage";
|
||||
import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage";
|
||||
|
|
@ -104,7 +105,8 @@ function ProtectedApp() {
|
|||
<Route path="devices" element={<NetworkDevicesPage />} />
|
||||
<Route path="alarms" element={<NetworkAlarmsPage />} />
|
||||
<Route path="configs" element={<NetworkConfigsPage />} />
|
||||
<Route path="topology" element={<Navigate to="/topology" replace />} />
|
||||
<Route path="topology/lldp" element={<LldpLinksPage />} />
|
||||
<Route path="topology" element={<Navigate to="/network/topology/lldp" replace />} />
|
||||
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
|
||||
<Route path="tasks/collect" element={<CollectPage />} />
|
||||
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Add leaf items here when extending (devices / topology / alarms / tasks).
|
||||
*/
|
||||
|
||||
export type NetworkNavGroupId = "detail" | "tasks";
|
||||
export type NetworkNavGroupId = "detail" | "topologyMgmt" | "tasks";
|
||||
|
||||
export type NetworkNavItem = {
|
||||
id: string;
|
||||
|
|
@ -28,6 +28,18 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
|
|||
{ id: "configs", path: "/network/configs", labelKey: "network.nav.configs", group: "detail" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "topologyMgmt",
|
||||
labelKey: "network.nav.topologyMgmt",
|
||||
items: [
|
||||
{
|
||||
id: "lldp-links",
|
||||
path: "/network/topology/lldp",
|
||||
labelKey: "network.nav.lldpLinks",
|
||||
group: "topologyMgmt",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
labelKey: "network.nav.tasks",
|
||||
|
|
|
|||
|
|
@ -46,8 +46,17 @@ 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,
|
||||
topologyViews: ["topologyViews"] as const,
|
||||
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
|
||||
fabricSummary: ["fabricSummary"] as const,
|
||||
/** @deprecated */
|
||||
topologyMaps: ["topologyViews"] as const,
|
||||
topologyGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
|
||||
lldpCollectDashboard: ["lldpCollectDashboard"] as const,
|
||||
lldpCollectJobsAll: ["lldpCollectJobs"] as const,
|
||||
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
|
||||
lldpCollectJobAll: ["lldpCollectJob"] as const,
|
||||
lldpCollectJob: (jobId: string) => ["lldpCollectJob", jobId] as const,
|
||||
configSyncDashboard: ["configSyncDashboard"] as const,
|
||||
configSyncPolicy: ["configSyncPolicy"] as const,
|
||||
configSyncCyclesAll: ["configSyncCycles"] as const,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const en = {
|
|||
webcrt: "WebCRT",
|
||||
webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)",
|
||||
topology: "Topology",
|
||||
topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP",
|
||||
topologyDesc: "Fabric topology views; LLDP physical discovery; MCP paged graph query",
|
||||
portTrafficWall: "Traffic wall",
|
||||
portTrafficWallDesc: "Named traffic boards in dedicated tabs",
|
||||
users: "Users",
|
||||
|
|
@ -48,8 +48,10 @@ const en = {
|
|||
nav: {
|
||||
detail: "Network details",
|
||||
tasks: "Monitoring tasks",
|
||||
topologyMgmt: "Topology management",
|
||||
devices: "Device list",
|
||||
topology: "Topology",
|
||||
lldpLinks: "LLDP links",
|
||||
alarms: "Alarms",
|
||||
configs: "Configurations",
|
||||
webcrt: "WebCRT",
|
||||
|
|
@ -225,6 +227,49 @@ const en = {
|
|||
vendor: "Vendor",
|
||||
},
|
||||
},
|
||||
lldpLinks: {
|
||||
title: "LLDP links",
|
||||
collectNow: "Collect now",
|
||||
started: "LLDP collect started",
|
||||
policyTitle: "Collect policy",
|
||||
policySaved: "Policy saved",
|
||||
enabled: "Enable scheduled collect",
|
||||
autoAddUnmatched: "Create SSH placeholder NEs for unmatched neighbors",
|
||||
intervalDays: "Interval (days)",
|
||||
concurrency: "Concurrency",
|
||||
scope: "Scope",
|
||||
scopeAll: "All managed NEs",
|
||||
scopeSelected: "Selected NEs",
|
||||
savePolicy: "Save policy",
|
||||
selectedCount: "{{count}} selected",
|
||||
targetKeywordPh: "Name / IP / vendor",
|
||||
jobsTitle: "Collect jobs",
|
||||
jobDetailTitle: "Job details",
|
||||
kpi: {
|
||||
nodes: "Fabric nodes",
|
||||
edges: "Active links",
|
||||
missing: "Missing",
|
||||
running: "In progress",
|
||||
idle: "Idle",
|
||||
last: "Last job",
|
||||
nextDue: "Next due",
|
||||
},
|
||||
col: {
|
||||
source: "Source",
|
||||
name: "Device",
|
||||
vendor: "Vendor",
|
||||
trigger: "Trigger",
|
||||
scope: "Scope",
|
||||
status: "Status",
|
||||
progress: "Progress",
|
||||
edges: "Edges",
|
||||
missingDelta: "Missing/purged",
|
||||
started: "Started",
|
||||
ended: "Ended",
|
||||
neighbors: "Neighbors",
|
||||
error: "Error",
|
||||
},
|
||||
},
|
||||
configSync: {
|
||||
title: "Config sync",
|
||||
syncNow: "Sync now",
|
||||
|
|
@ -924,7 +969,6 @@ const en = {
|
|||
},
|
||||
newSession: {
|
||||
title: "New Session",
|
||||
hint: "Enter host and protocol. SSH prompts for username/password (editable on retry); Telnet logs in interactively. Duplicate names become (1), (2)…; same IP is allowed.",
|
||||
connect: "Connect",
|
||||
connecting: "Connecting…",
|
||||
protocol: "Protocol",
|
||||
|
|
@ -1154,7 +1198,7 @@ const en = {
|
|||
sourcePort: "Local port",
|
||||
targetPort: "Remote port",
|
||||
canvasHint:
|
||||
"Use toolbar modes (select/pan/drag/connect), box-select, and layout. Blue dashed=discovered, red=missing. Shortcuts V/H/A/C, Ctrl+Z undo.",
|
||||
"Add NEs from the palette, drag then save positions; LLDP discovery writes the global fabric. Blue dashed=discovered, red=stale.",
|
||||
selected: "Selected",
|
||||
selectedEdge: "Selected edge",
|
||||
selectedCount: "{{count}} selected",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const zh = {
|
|||
webcrt: "WebCRT",
|
||||
webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)",
|
||||
topology: "拓扑管理",
|
||||
topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路",
|
||||
topologyDesc: "Fabric 拓扑视图;LLDP 发现物理链路,MCP 可分页拉图",
|
||||
portTrafficWall: "流量大屏",
|
||||
portTrafficWallDesc: "独立页签打开命名流量大屏",
|
||||
users: "用户管理",
|
||||
|
|
@ -48,8 +48,10 @@ const zh = {
|
|||
nav: {
|
||||
detail: "网络详情",
|
||||
tasks: "监控任务",
|
||||
topologyMgmt: "拓扑管理",
|
||||
devices: "设备列表",
|
||||
topology: "拓扑信息",
|
||||
lldpLinks: "LLDP 链路",
|
||||
alarms: "告警信息",
|
||||
configs: "配置信息",
|
||||
webcrt: "WebCRT",
|
||||
|
|
@ -225,6 +227,49 @@ const zh = {
|
|||
vendor: "厂商",
|
||||
},
|
||||
},
|
||||
lldpLinks: {
|
||||
title: "LLDP 链路",
|
||||
collectNow: "立即采集",
|
||||
started: "已启动 LLDP 采集",
|
||||
policyTitle: "采集策略",
|
||||
policySaved: "策略已保存",
|
||||
enabled: "启用周期调度",
|
||||
autoAddUnmatched: "未纳管邻居建 SSH 占位网元",
|
||||
intervalDays: "周期(天)",
|
||||
concurrency: "并发",
|
||||
scope: "范围",
|
||||
scopeAll: "全部纳管网元",
|
||||
scopeSelected: "指定网元",
|
||||
savePolicy: "保存策略",
|
||||
selectedCount: "已选 {{count}} 台",
|
||||
targetKeywordPh: "名称 / IP / 厂商",
|
||||
jobsTitle: "采集任务",
|
||||
jobDetailTitle: "任务明细",
|
||||
kpi: {
|
||||
nodes: "Fabric 网元",
|
||||
edges: "活跃链路",
|
||||
missing: "未发现",
|
||||
running: "进行中",
|
||||
idle: "空闲",
|
||||
last: "最近任务",
|
||||
nextDue: "下次调度",
|
||||
},
|
||||
col: {
|
||||
source: "来源",
|
||||
name: "设备",
|
||||
vendor: "厂商",
|
||||
trigger: "触发",
|
||||
scope: "范围",
|
||||
status: "状态",
|
||||
progress: "进度",
|
||||
edges: "链路",
|
||||
missingDelta: "未发现/清理",
|
||||
started: "开始",
|
||||
ended: "结束",
|
||||
neighbors: "邻居",
|
||||
error: "错误",
|
||||
},
|
||||
},
|
||||
configSync: {
|
||||
title: "配置同步",
|
||||
syncNow: "立即全量同步",
|
||||
|
|
@ -921,7 +966,6 @@ const zh = {
|
|||
},
|
||||
newSession: {
|
||||
title: "新建会话",
|
||||
hint: "填写主机与协议后连接。SSH 弹出用户名/密码(可改);Telnet 进终端交互登录。同名会话自动加 (1)、(2)…,允许相同 IP。",
|
||||
connect: "连接",
|
||||
connecting: "连接中…",
|
||||
protocol: "协议",
|
||||
|
|
@ -1151,7 +1195,7 @@ const zh = {
|
|||
sourcePort: "本端端口",
|
||||
targetPort: "对端端口",
|
||||
canvasHint:
|
||||
"工具栏切换选择/平移/拖动/连线;框选多选;布局可自动构图。蓝虚线=发现链路,红虚线=未发现。快捷键 V/H/A/C,Ctrl+Z 撤销。",
|
||||
"从网元库上图,拖拽后保存坐标;LLDP 发现写入全局 Fabric。蓝虚线=发现链路,红虚线=stale。",
|
||||
selected: "已选节点",
|
||||
selectedEdge: "已选链路",
|
||||
selectedCount: "已选 {{count}}",
|
||||
|
|
|
|||
|
|
@ -3884,6 +3884,22 @@ pre {
|
|||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topo-toolbar__actions > .btn,
|
||||
.topo-toolbar__group > .btn {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.topo-toolbar__group > select.topo-toolbar__find {
|
||||
width: auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.topo-toolbar__group {
|
||||
|
|
|
|||
|
|
@ -119,9 +119,25 @@ export function ConfigSyncPage() {
|
|||
history_keep: historyKeep,
|
||||
selected_targets: Object.values(selectedMap),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (saved) => {
|
||||
// Apply server response immediately — do NOT flip policyHydrated false then
|
||||
// rehydrate from a possibly-stale dashboard cache (checkbox "pops back" bug).
|
||||
setEnabled(Boolean(saved.enabled));
|
||||
setIntervalDays(Number(saved.interval_days || 3));
|
||||
setConcurrency(Number(saved.concurrency || 5));
|
||||
setScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
|
||||
setHistoryKeep(Number(saved.history_keep ?? 3));
|
||||
const map: Record<string, ConfigSyncTargetRef> = {};
|
||||
for (const ref of saved.selected_targets || []) {
|
||||
map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id };
|
||||
}
|
||||
setSelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
queryClient.setQueryData(queryKeys.configSyncDashboard, (prev: unknown) => {
|
||||
if (!prev || typeof prev !== "object") return prev;
|
||||
return { ...(prev as object), policy: saved };
|
||||
});
|
||||
showOk(t("configSync.policySaved"));
|
||||
setPolicyHydrated(false);
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ReactFlow,
|
||||
|
|
@ -23,16 +23,23 @@ import {
|
|||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
addTopologyViewNodes,
|
||||
createFabricManualEdge,
|
||||
createTopologyMap,
|
||||
deleteTopologyMap,
|
||||
discoverTopologyNeighborsStream,
|
||||
fetchLldpDiscoverJob,
|
||||
fetchManagedNe,
|
||||
fetchTopologyGraph,
|
||||
fetchTopologyMaps,
|
||||
fetchUmeNe,
|
||||
putTopologyGraph,
|
||||
patchTopologyEdgeStyle,
|
||||
patchTopologyPositions,
|
||||
projectTopologyNeighbors,
|
||||
removeTopologyViewNodes,
|
||||
startLldpDiscover,
|
||||
updateTopologyMap,
|
||||
} from "../services/api";
|
||||
import type { TopologyDiscoverJob, TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem } from "../types";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { HelpHint } from "../components/HelpHint";
|
||||
import { useI18n } from "../i18n";
|
||||
|
|
@ -399,27 +406,32 @@ function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge {
|
|||
return { ...edge, style, markerEnd: edgeMarker(style.stroke) };
|
||||
}
|
||||
|
||||
function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defaults: EdgeDefaults) {
|
||||
function graphToFlow(
|
||||
nodes: TopologyViewNodeItem[],
|
||||
edges: TopologyViewEdgeItem[],
|
||||
defaults: EdgeDefaults,
|
||||
) {
|
||||
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
id: n.fabric_node_id,
|
||||
type: "neNode",
|
||||
position: { x: n.x || 0, y: n.y || 0 },
|
||||
data: {
|
||||
label: n.label || n.ne_name || n.ne_ip || n.id,
|
||||
label: n.label || n.name || n.ip || n.fabric_node_id,
|
||||
managed_ne_id: n.managed_ne_id || "",
|
||||
ume_ne_id: n.ume_ne_id || "",
|
||||
ne_ip: n.ne_ip || "",
|
||||
ne_ip: n.ip || "",
|
||||
vendor: n.vendor || "",
|
||||
connect_status: n.connect_status || "",
|
||||
},
|
||||
}));
|
||||
const rfEdges: Edge[] = edges.map((e) => {
|
||||
const src = e.source || "manual";
|
||||
const label = [e.source_port, e.target_port].filter(Boolean).join(SEP);
|
||||
const src =
|
||||
e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual";
|
||||
const label = [e.a_port, e.b_port].filter(Boolean).join(SEP);
|
||||
const data: EdgeStyleData = {
|
||||
source: src,
|
||||
source_port: e.source_port || "",
|
||||
target_port: e.target_port || "",
|
||||
source_port: e.a_port || "",
|
||||
target_port: e.b_port || "",
|
||||
stroke_color: e.stroke_color || "",
|
||||
stroke_width: Number(e.stroke_width || 0),
|
||||
line_style: e.line_style || "",
|
||||
|
|
@ -428,8 +440,8 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau
|
|||
return withEdgeVisual(
|
||||
{
|
||||
id: e.id,
|
||||
source: e.source_node_id,
|
||||
target: e.target_node_id,
|
||||
source: e.a_node_id,
|
||||
target: e.b_node_id,
|
||||
type: "straight",
|
||||
label: label || undefined,
|
||||
animated: false,
|
||||
|
|
@ -441,32 +453,25 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau
|
|||
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) => {
|
||||
const data = (e.data || {}) as EdgeStyleData;
|
||||
return {
|
||||
id: e.id,
|
||||
source_node_id: e.source,
|
||||
target_node_id: e.target,
|
||||
source_port: String(data.source_port || ""),
|
||||
target_port: String(data.target_port || ""),
|
||||
source: String(data.source || "manual"),
|
||||
stroke_color: String(data.stroke_color || ""),
|
||||
stroke_width: Number(data.stroke_width || 0),
|
||||
line_style: String(data.line_style || ""),
|
||||
discovered_at: data.discovered_at ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
function flowToPositions(nodes: Node<NeNodeData>[]) {
|
||||
return nodes.map((n) => ({
|
||||
fabric_node_id: n.id,
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
label: n.data.label || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function applyViewGraph(
|
||||
graph: TopologyViewGraph,
|
||||
defaults: EdgeDefaults,
|
||||
setNodes: (ns: Node<NeNodeData>[]) => void,
|
||||
setEdges: (es: Edge[]) => void,
|
||||
) {
|
||||
const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults);
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
return { rfNodes, rfEdges };
|
||||
}
|
||||
|
||||
export function TopologyPage() {
|
||||
|
|
@ -731,7 +736,7 @@ export function TopologyPage() {
|
|||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 40);
|
||||
if (opts?.persist && mapId) {
|
||||
try {
|
||||
const graph = await putTopologyGraph(mapId, flowToGraphPayload(next, edges));
|
||||
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
|
||||
clearDirty();
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
} catch (err) {
|
||||
|
|
@ -804,7 +809,7 @@ export function TopologyPage() {
|
|||
});
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)),
|
||||
mutationFn: () => patchTopologyPositions(mapId, flowToPositions(nodes)),
|
||||
onSuccess: async (graph) => {
|
||||
clearDirty();
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
|
|
@ -817,10 +822,12 @@ export function TopologyPage() {
|
|||
const runDiscover = useCallback(
|
||||
async (neIds?: string[]) => {
|
||||
if (!mapId || discovering) return;
|
||||
// Topology canvas: single-NE (or explicit id list) only — bulk collect lives under Network → LLDP.
|
||||
const filterIds = (neIds || []).map((x) => String(x || "").trim()).filter(Boolean);
|
||||
discoverAbortRef.current?.abort();
|
||||
const ac = new AbortController();
|
||||
discoverAbortRef.current = ac;
|
||||
if (!filterIds.length) {
|
||||
showError(t("topology.discoverOneNeedNe"));
|
||||
return;
|
||||
}
|
||||
setDiscoverOpen(true);
|
||||
setDiscovering(true);
|
||||
setDiscoverReport(null);
|
||||
|
|
@ -830,13 +837,11 @@ export function TopologyPage() {
|
|||
setDiscoverDetail(null);
|
||||
setDiscoverListOpen(false);
|
||||
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
|
||||
const scoped = filterIds.length
|
||||
? scannable.filter(
|
||||
(n) =>
|
||||
filterIds.includes(String(n.data.managed_ne_id || "")) ||
|
||||
filterIds.includes(String(n.data.ume_ne_id || "")),
|
||||
)
|
||||
: scannable;
|
||||
const scoped = scannable.filter(
|
||||
(n) =>
|
||||
filterIds.includes(String(n.data.managed_ne_id || "")) ||
|
||||
filterIds.includes(String(n.data.ume_ne_id || "")),
|
||||
);
|
||||
setDiscoverProgress({
|
||||
index: 0,
|
||||
total: scoped.length,
|
||||
|
|
@ -847,71 +852,76 @@ export function TopologyPage() {
|
|||
});
|
||||
try {
|
||||
if (dirtyRef.current) {
|
||||
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
|
||||
await patchTopologyPositions(mapId, flowToPositions(nodes));
|
||||
clearDirty();
|
||||
}
|
||||
const out = await discoverTopologyNeighborsStream(
|
||||
mapId,
|
||||
{
|
||||
protocol: "auto",
|
||||
...(filterIds.length ? { ne_ids: filterIds } : {}),
|
||||
},
|
||||
{
|
||||
onStart: (ev) => {
|
||||
setDiscoverProgress((p) => ({ ...p, total: ev.total, index: 0 }));
|
||||
},
|
||||
onNeStart: (ev) => {
|
||||
setDiscoverProgress((p) => ({
|
||||
...p,
|
||||
index: ev.index,
|
||||
total: ev.total,
|
||||
neName: ev.ne_name || ev.ne_ip || ev.ne_id,
|
||||
neIp: ev.ne_ip,
|
||||
}));
|
||||
},
|
||||
onNeResult: (ev) => {
|
||||
if (ev.result) {
|
||||
setDiscoverLiveResults((prev) => [...prev, ev.result]);
|
||||
}
|
||||
setDiscoverProgress((p) => ({
|
||||
...p,
|
||||
index: ev.index,
|
||||
total: ev.total,
|
||||
edgesAdded: ev.edges_added,
|
||||
edgesUpdated: ev.edges_updated,
|
||||
}));
|
||||
},
|
||||
},
|
||||
ac.signal,
|
||||
);
|
||||
if (out.graph) {
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
|
||||
let { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges, edgeDefaults);
|
||||
let didAutoLayout = false;
|
||||
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
||||
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||
didAutoLayout = true;
|
||||
try {
|
||||
const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges));
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
clearDirty();
|
||||
} catch {
|
||||
markDirty();
|
||||
}
|
||||
} else {
|
||||
clearDirty();
|
||||
}
|
||||
historyLockRef.current = true;
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
historyLockRef.current = false;
|
||||
// Only refit when layout changed; otherwise keep the user's viewport.
|
||||
if (didAutoLayout) {
|
||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||
}
|
||||
const ne_ids = filterIds;
|
||||
if (!scoped.length) {
|
||||
throw new Error(t("topology.discoverOneNeedNe"));
|
||||
}
|
||||
const jobStart = await startLldpDiscover({
|
||||
scope: "ne_ids",
|
||||
ne_ids,
|
||||
concurrency: 4,
|
||||
auto_add_unmatched: true,
|
||||
trigger_mode: "topology",
|
||||
});
|
||||
let job: TopologyDiscoverJob = jobStart;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
await new Promise((r) => window.setTimeout(r, 500));
|
||||
job = await fetchLldpDiscoverJob(jobStart.id);
|
||||
setDiscoverProgress((p) => ({
|
||||
...p,
|
||||
index: job.done,
|
||||
total: job.total || p.total,
|
||||
edgesAdded: job.edges_added,
|
||||
edgesUpdated: job.edges_updated,
|
||||
neName: job.items?.[job.items.length - 1]?.ne_name || p.neName,
|
||||
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
|
||||
}));
|
||||
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
|
||||
if (job.status === "done" || job.status === "failed") break;
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
throw new Error(job.error || "discover_failed");
|
||||
}
|
||||
const projected = await projectTopologyNeighbors(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
|
||||
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
|
||||
let didAutoLayout = false;
|
||||
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
||||
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||
didAutoLayout = true;
|
||||
try {
|
||||
const graph = await patchTopologyPositions(mapId, flowToPositions(rfNodes));
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
clearDirty();
|
||||
} catch {
|
||||
markDirty();
|
||||
}
|
||||
} else {
|
||||
clearDirty();
|
||||
}
|
||||
historyLockRef.current = true;
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
historyLockRef.current = false;
|
||||
if (didAutoLayout) {
|
||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||
}
|
||||
const out: TopologyDiscoverOut = {
|
||||
map_id: mapId,
|
||||
protocol: "lldp",
|
||||
scanned: job.done,
|
||||
edges_added: job.edges_added,
|
||||
edges_updated: job.edges_updated,
|
||||
edges_stale: job.edges_stale,
|
||||
results: (job.items || []) as TopologyDiscoverNeResult[],
|
||||
graph: projected,
|
||||
};
|
||||
setDiscoverReport(out);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.fabricSummary });
|
||||
showOk(
|
||||
t("topology.discovered")
|
||||
.replace("{{added}}", String(out.edges_added))
|
||||
|
|
@ -919,15 +929,13 @@ export function TopologyPage() {
|
|||
.replace("{{stale}}", String(out.edges_stale || 0)),
|
||||
);
|
||||
} catch (err) {
|
||||
if (ac.signal.aborted) return;
|
||||
setDiscoverError(String(err));
|
||||
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
|
||||
} finally {
|
||||
if (discoverAbortRef.current === ac) discoverAbortRef.current = null;
|
||||
setDiscovering(false);
|
||||
}
|
||||
},
|
||||
[mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults],
|
||||
[mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults, clearDirty, markDirty],
|
||||
);
|
||||
|
||||
const discoverResults = discoverReport?.results?.length
|
||||
|
|
@ -989,91 +997,84 @@ export function TopologyPage() {
|
|||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!isValidConnection(connection)) return;
|
||||
if (!mapId || !isValidConnection(connection)) return;
|
||||
pushHistory();
|
||||
markDirty();
|
||||
connectClickRef.current = null;
|
||||
const data: EdgeStyleData = {
|
||||
source: "manual",
|
||||
source_port: "",
|
||||
target_port: "",
|
||||
stroke_color: "",
|
||||
stroke_width: 0,
|
||||
line_style: "",
|
||||
discovered_at: null,
|
||||
};
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
withEdgeVisual(
|
||||
{
|
||||
...connection,
|
||||
id: newId(),
|
||||
type: "straight",
|
||||
data,
|
||||
},
|
||||
edgeDefaults,
|
||||
),
|
||||
eds,
|
||||
),
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
await createFabricManualEdge({
|
||||
a_node_id: String(connection.source || ""),
|
||||
b_node_id: String(connection.target || ""),
|
||||
a_port: "",
|
||||
b_port: "",
|
||||
});
|
||||
const graph = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
})();
|
||||
},
|
||||
[setEdges, pushHistory, edgeDefaults, markDirty, isValidConnection],
|
||||
[setEdges, setNodes, pushHistory, edgeDefaults, isValidConnection, mapId, queryClient, clearDirty, showError],
|
||||
);
|
||||
|
||||
const addNodeAt = useCallback(
|
||||
(item: PaletteItem, position: { x: number; y: number }) => {
|
||||
async (item: PaletteItem, position: { x: number; y: number }) => {
|
||||
if (!mapId) {
|
||||
showError(t("topology.selectMap"));
|
||||
return;
|
||||
}
|
||||
if (item.source === "managed") {
|
||||
if (nodes.some((n) => n.data.managed_ne_id === item.managed_ne_id)) return;
|
||||
const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id);
|
||||
if (!ne) return;
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newId(),
|
||||
type: "neNode",
|
||||
position,
|
||||
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,
|
||||
try {
|
||||
if (item.source === "managed") {
|
||||
if (nodes.some((n) => n.data.managed_ne_id === item.managed_ne_id)) return;
|
||||
const graph = await addTopologyViewNodes(mapId, { managed_ne_ids: [item.managed_ne_id] });
|
||||
const added = graph.nodes.find((n) => n.managed_ne_id === item.managed_ne_id);
|
||||
if (added) {
|
||||
await patchTopologyPositions(mapId, [
|
||||
{
|
||||
fabric_node_id: added.fabric_node_id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
label: added.label || added.name || "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const refreshed = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
return;
|
||||
}
|
||||
if (nodes.some((n) => n.data.ume_ne_id === item.ume_ne_id)) return;
|
||||
const graph = await addTopologyViewNodes(mapId, { ume_ne_ids: [item.ume_ne_id] });
|
||||
const added = graph.nodes.find((n) => n.ume_ne_id === item.ume_ne_id);
|
||||
if (added) {
|
||||
await patchTopologyPositions(mapId, [
|
||||
{
|
||||
fabric_node_id: added.fabric_node_id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
label: added.label || added.name || "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
]);
|
||||
}
|
||||
const refreshed = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
if (nodes.some((n) => n.data.ume_ne_id === item.ume_ne_id)) return;
|
||||
const ne = (umeQuery.data?.items || []).find((x) => x.ne_id === item.ume_ne_id);
|
||||
if (!ne) return;
|
||||
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newId(),
|
||||
type: "neNode",
|
||||
position,
|
||||
data: {
|
||||
label: name,
|
||||
managed_ne_id: "",
|
||||
ume_ne_id: ne.ne_id,
|
||||
ne_ip: ne.ip_address || "",
|
||||
vendor: "ZTE",
|
||||
connect_status: ne.connection_status || "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
[mapId, nodes, neQuery.data, umeQuery.data, setNodes, pushHistory, showError, t],
|
||||
[mapId, nodes, setNodes, setEdges, showError, t, queryClient, edgeDefaults, clearDirty],
|
||||
);
|
||||
|
||||
const addManagedNeToCanvas = (ne: ManagedNeItem) => {
|
||||
|
|
@ -1161,9 +1162,8 @@ export function TopologyPage() {
|
|||
>,
|
||||
opts?: { skipHistory?: boolean },
|
||||
) => {
|
||||
if (!selectedEdgeId) return;
|
||||
if (!selectedEdgeId || !mapId) return;
|
||||
if (!opts?.skipHistory) pushHistory();
|
||||
markDirty();
|
||||
setEdges((eds) =>
|
||||
eds.map((e) => {
|
||||
if (e.id !== selectedEdgeId) return e;
|
||||
|
|
@ -1181,8 +1181,17 @@ export function TopologyPage() {
|
|||
return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults);
|
||||
}),
|
||||
);
|
||||
const edge = edges.find((e) => e.id === selectedEdgeId);
|
||||
const prev = (edge?.data || {}) as EdgeStyleData;
|
||||
void patchTopologyEdgeStyle(mapId, {
|
||||
fabric_edge_id: selectedEdgeId,
|
||||
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
|
||||
stroke_width:
|
||||
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
|
||||
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
|
||||
}).catch((err) => showError(String(err)));
|
||||
},
|
||||
[selectedEdgeId, setEdges, pushHistory, edgeDefaults, markDirty],
|
||||
[selectedEdgeId, mapId, edges, setEdges, pushHistory, edgeDefaults, showError],
|
||||
);
|
||||
|
||||
const renameSelectedNode = useCallback(() => {
|
||||
|
|
@ -1202,13 +1211,6 @@ export function TopologyPage() {
|
|||
setCtxMenu(null);
|
||||
}, [selectedNode, t, pushHistory, markDirty, setNodes]);
|
||||
|
||||
const staleEdgeCount = useMemo(
|
||||
() =>
|
||||
edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale")
|
||||
.length,
|
||||
[edges],
|
||||
);
|
||||
|
||||
const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1251,19 +1253,29 @@ export function TopologyPage() {
|
|||
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const removeSelected = useCallback(() => {
|
||||
const nodeIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id));
|
||||
const removeSelected = useCallback(async () => {
|
||||
if (!mapId) return;
|
||||
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
|
||||
const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id));
|
||||
if (nodeIds.size === 0 && edgeIds.size === 0) return;
|
||||
if (!nodeIds.length && !edgeIds.size) return;
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setNodes((ns) => ns.filter((n) => !nodeIds.has(n.id)));
|
||||
setEdges((es) =>
|
||||
es.filter((e) => !edgeIds.has(e.id) && !nodeIds.has(e.source) && !nodeIds.has(e.target)),
|
||||
);
|
||||
setSelectedEdgeId(null);
|
||||
closeCtxMenu();
|
||||
}, [nodes, edges, setNodes, setEdges, pushHistory, closeCtxMenu]);
|
||||
try {
|
||||
if (nodeIds.length) {
|
||||
const graph = await removeTopologyViewNodes(mapId, nodeIds);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
} else if (edgeIds.size) {
|
||||
setEdges((eds) => eds.filter((e) => !edgeIds.has(e.id)));
|
||||
markDirty();
|
||||
}
|
||||
setSelectedEdgeId(null);
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
}, [mapId, nodes, edges, setNodes, setEdges, pushHistory, queryClient, edgeDefaults, clearDirty, markDirty, showError]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
|
|
@ -1350,18 +1362,6 @@ export function TopologyPage() {
|
|||
closeCtxMenu();
|
||||
};
|
||||
|
||||
const removeStaleEdges = () => {
|
||||
const n = staleEdgeCount;
|
||||
if (n <= 0) return;
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setEdges((es) =>
|
||||
es.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") !== "stale"),
|
||||
);
|
||||
setSelectedEdgeId(null);
|
||||
showOk(t("topology.staleRemoved").replace("{{count}}", String(n)));
|
||||
};
|
||||
|
||||
const openWebcrtFor = (node: Node<NeNodeData> | null) => {
|
||||
closeCtxMenu();
|
||||
const managedId = node?.data.managed_ne_id;
|
||||
|
|
@ -1894,23 +1894,6 @@ export function TopologyPage() {
|
|||
? t("topology.saveDirty")
|
||||
: t("topology.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={!mapId || discovering}
|
||||
onClick={() => void runDiscover()}
|
||||
>
|
||||
{discovering ? t("topology.discovering") : t("topology.discover")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!mapId || discovering || staleEdgeCount <= 0}
|
||||
onClick={removeStaleEdges}
|
||||
title={t("topology.removeStaleHint")}
|
||||
>
|
||||
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
|
|
@ -2191,8 +2174,7 @@ export function TopologyPage() {
|
|||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
if (discovering) {
|
||||
discoverAbortRef.current?.abort();
|
||||
return;
|
||||
return;
|
||||
}
|
||||
setDiscoverOpen(false);
|
||||
}}
|
||||
|
|
@ -2391,28 +2373,6 @@ export function TopologyPage() {
|
|||
<li className="topo-ctx__head" role="presentation">
|
||||
{t("topology.selectionMenu")}
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-ctx__item"
|
||||
role="menuitem"
|
||||
disabled={discovering}
|
||||
onClick={() => {
|
||||
const ids = selectedNodes
|
||||
.map((n) => String(n.data.managed_ne_id || n.data.ume_ne_id || "").trim())
|
||||
.filter(Boolean);
|
||||
closeCtxMenu();
|
||||
if (!ids.length) {
|
||||
showError(t("topology.discoverOneNeedNe"));
|
||||
return;
|
||||
}
|
||||
void runDiscover(ids);
|
||||
}}
|
||||
>
|
||||
{t("topology.discoverSelected").replace("{{count}}", String(selectedNodes.length))}
|
||||
</button>
|
||||
</li>
|
||||
<li className="topo-ctx__sep" aria-hidden />
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
581
web/src/pages/network/LldpLinksPage.tsx
Normal file
581
web/src/pages/network/LldpLinksPage.tsx
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchCliTargets,
|
||||
fetchLldpCollectDashboard,
|
||||
fetchLldpCollectJob,
|
||||
fetchLldpCollectJobs,
|
||||
startLldpCollect,
|
||||
updateLldpCollectPolicy,
|
||||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import type { CliTargetItem, ConfigSyncTargetRef, TopologyDiscoverJobItem } from "../../types";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
const POLL_MS = 2500;
|
||||
const TARGET_PAGE_SIZE = 20;
|
||||
const SEP = " · ";
|
||||
|
||||
export function LldpLinksPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalDays, setIntervalDays] = useState(1);
|
||||
const [concurrency, setConcurrency] = useState(4);
|
||||
const [scopeMode, setScopeMode] = useState<"all" | "selected">("all");
|
||||
const [autoAdd, setAutoAdd] = useState(true);
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({});
|
||||
const [policyHydrated, setPolicyHydrated] = useState(false);
|
||||
|
||||
const [targetKeyword, setTargetKeyword] = useState("");
|
||||
const [targetPage, setTargetPage] = useState(1);
|
||||
|
||||
const dashQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectDashboard,
|
||||
queryFn: fetchLldpCollectDashboard,
|
||||
staleTime: 1000,
|
||||
refetchInterval: (q) => {
|
||||
const running = q.state.data?.running_job;
|
||||
return running && (running.status === "running" || running.status === "pending") ? POLL_MS : false;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashQuery.data || policyHydrated) return;
|
||||
const p = dashQuery.data.policy;
|
||||
setEnabled(Boolean(p.enabled));
|
||||
setIntervalDays(Number(p.interval_days || 1));
|
||||
setConcurrency(Number(p.concurrency || 4));
|
||||
setScopeMode(p.scope_mode === "selected" ? "selected" : "all");
|
||||
setAutoAdd(Boolean(p.auto_add_unmatched));
|
||||
const map: Record<string, ConfigSyncTargetRef> = {};
|
||||
for (const ref of p.selected_targets || []) {
|
||||
const source = ref.source === "ume" ? "ume" : "managed";
|
||||
map[`${source}:${ref.id}`] = { source, id: ref.id };
|
||||
}
|
||||
setSelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
}, [dashQuery.data, policyHydrated]);
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJobs(jobPage),
|
||||
queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: 10 }),
|
||||
staleTime: 1000,
|
||||
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const jobDetailQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJob(expandedJobId),
|
||||
queryFn: () => fetchLldpCollectJob(expandedJobId),
|
||||
enabled: Boolean(expandedJobId),
|
||||
staleTime: 800,
|
||||
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const targetsQuery = useQuery({
|
||||
queryKey: queryKeys.cliTargets(targetKeyword, targetPage, TARGET_PAGE_SIZE),
|
||||
queryFn: () =>
|
||||
fetchCliTargets({ source: "all", keyword: targetKeyword, page: targetPage, pageSize: TARGET_PAGE_SIZE }),
|
||||
enabled: scopeMode === "selected",
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobsAll }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobAll }),
|
||||
]);
|
||||
};
|
||||
|
||||
const savePolicyMut = useMutation({
|
||||
mutationFn: () =>
|
||||
updateLldpCollectPolicy({
|
||||
enabled,
|
||||
interval_days: intervalDays,
|
||||
concurrency,
|
||||
scope_mode: scopeMode,
|
||||
auto_add_unmatched: autoAdd,
|
||||
selected_targets: Object.values(selectedMap),
|
||||
}),
|
||||
onSuccess: async (saved) => {
|
||||
// Apply server response immediately — do NOT flip policyHydrated false then
|
||||
// rehydrate from a possibly-stale dashboard cache (checkbox "pops back" bug).
|
||||
setEnabled(Boolean(saved.enabled));
|
||||
setIntervalDays(Number(saved.interval_days || 1));
|
||||
setConcurrency(Number(saved.concurrency || 4));
|
||||
setScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
|
||||
setAutoAdd(Boolean(saved.auto_add_unmatched));
|
||||
const map: Record<string, ConfigSyncTargetRef> = {};
|
||||
for (const ref of saved.selected_targets || []) {
|
||||
const source = ref.source === "ume" ? "ume" : "managed";
|
||||
map[`${source}:${ref.id}`] = { source, id: ref.id };
|
||||
}
|
||||
setSelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
queryClient.setQueryData(queryKeys.lldpCollectDashboard, (prev: unknown) => {
|
||||
if (!prev || typeof prev !== "object") return prev;
|
||||
return { ...(prev as object), policy: saved };
|
||||
});
|
||||
showOk(t("lldpLinks.policySaved"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const startMut = useMutation({
|
||||
mutationFn: () => startLldpCollect(),
|
||||
onSuccess: async () => {
|
||||
showOk(t("lldpLinks.started"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const dash = dashQuery.data;
|
||||
const running = dash?.running_job;
|
||||
const last = dash?.last_job;
|
||||
const jobs = jobsQuery.data?.items ?? [];
|
||||
const jobTotal = Number(jobsQuery.data?.total || 0);
|
||||
const jobPages = pageCount(jobTotal, 10);
|
||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||
const detailItems = jobDetailQuery.data?.items ?? [];
|
||||
|
||||
const toggleTarget = (row: CliTargetItem) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
const key = `${source}:${row.id}`;
|
||||
setSelectedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[key]) delete next[key];
|
||||
else next[key] = { source, id: row.id };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("lldpLinks.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" onClick={() => void refresh()} disabled={dashQuery.isFetching}>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={Boolean(running) || startMut.isPending}
|
||||
onClick={() => startMut.mutate()}
|
||||
>
|
||||
{t("lldpLinks.collectNow")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list" style={{ marginBottom: 16 }}>
|
||||
<div className="pt-list-kpis">
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.nodes")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.fabric_node_count ?? "—"}</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.edges")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.fabric_edge_active ?? "—"}</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.missing")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.fabric_edge_stale ?? "—"}</div>
|
||||
</div>
|
||||
<div className={`pt-list-kpi${running ? " pt-list-kpi--live" : ""}`}>
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.running")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: running ? 15 : 22 }}>
|
||||
{running
|
||||
? `${running.status} · ${running.done}/${running.total}`
|
||||
: t("lldpLinks.kpi.idle")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.last")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
|
||||
{last
|
||||
? `${last.status} · +${last.edges_added} / ~${last.edges_updated}`
|
||||
: t("common.empty")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("lldpLinks.kpi.nextDue")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
|
||||
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ marginBottom: 16 }}>
|
||||
<h3>{t("lldpLinks.policyTitle")}</h3>
|
||||
<div className="config-sync-policy-row">
|
||||
<label className="config-sync-policy-check">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
<span>{t("lldpLinks.enabled")}</span>
|
||||
</label>
|
||||
<label className="config-sync-policy-check">
|
||||
<input type="checkbox" checked={autoAdd} onChange={(e) => setAutoAdd(e.target.checked)} />
|
||||
<span>{t("lldpLinks.autoAddUnmatched")}</span>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.intervalDays")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.concurrency")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={32}
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Math.max(1, Math.min(32, Number(e.target.value) || 1)))}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.scope")}</span>
|
||||
<select
|
||||
value={scopeMode}
|
||||
onChange={(e) => setScopeMode(e.target.value === "selected" ? "selected" : "all")}
|
||||
>
|
||||
<option value="all">{t("lldpLinks.scopeAll")}</option>
|
||||
<option value="selected">{t("lldpLinks.scopeSelected")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={savePolicyMut.isPending}
|
||||
onClick={() => savePolicyMut.mutate()}
|
||||
>
|
||||
{t("lldpLinks.savePolicy")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scopeMode === "selected" ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p className="muted">{t("lldpLinks.selectedCount", { count: String(selectedCount) })}</p>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={targetKeyword}
|
||||
placeholder={t("lldpLinks.targetKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setTargetKeyword(e.target.value);
|
||||
setTargetPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t("lldpLinks.col.source")}</th>
|
||||
<th>{t("lldpLinks.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("lldpLinks.col.vendor")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(targetsQuery.data?.items ?? []).map((row) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
const key = `${source}:${row.id}`;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(selectedMap[key])}
|
||||
onChange={() => toggleTarget(row)}
|
||||
/>
|
||||
</td>
|
||||
<td>{source}</td>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>{row.vendor || "-"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={targetPage <= 1} onClick={() => setTargetPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(targetsQuery.data?.total ?? 0),
|
||||
page: String(targetPage),
|
||||
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
|
||||
onClick={() => setTargetPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h3>{t("lldpLinks.jobsTitle")}</h3>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>ID</th>
|
||||
<th>{t("lldpLinks.col.trigger")}</th>
|
||||
<th>{t("lldpLinks.col.scope")}</th>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.progress")}</th>
|
||||
<th>{t("lldpLinks.col.edges")}</th>
|
||||
<th>{t("lldpLinks.col.missingDelta")}</th>
|
||||
<th>{t("lldpLinks.col.started")}</th>
|
||||
<th>{t("lldpLinks.col.ended")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => {
|
||||
const open = expandedJobId === job.id;
|
||||
return (
|
||||
<tr key={job.id} className={open ? "is-expanded" : undefined}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
setItemDetail(null);
|
||||
setExpandedJobId(open ? "" : job.id);
|
||||
}}
|
||||
>
|
||||
{open ? "−" : "+"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="mono">{job.id.slice(0, 8)}</td>
|
||||
<td>{job.trigger_mode}</td>
|
||||
<td>{job.scope}</td>
|
||||
<td>{job.status}</td>
|
||||
<td>
|
||||
{job.done}/{job.total}
|
||||
</td>
|
||||
<td>
|
||||
+{job.edges_added} / ~{job.edges_updated}
|
||||
</td>
|
||||
<td>{job.edges_stale || 0}</td>
|
||||
<td>{job.started_at ? formatSystemTime(job.started_at) : "—"}</td>
|
||||
<td>{job.ended_at ? formatSystemTime(job.ended_at) : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!jobs.length ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="muted">
|
||||
{t("common.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={jobPage <= 1} onClick={() => setJobPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(jobTotal),
|
||||
page: String(jobPage),
|
||||
pages: String(jobPages),
|
||||
})}
|
||||
</span>
|
||||
<button type="button" disabled={jobPage >= jobPages} onClick={() => setJobPage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expandedJobId ? (
|
||||
<div className="panel" style={{ marginTop: 16 }}>
|
||||
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
|
||||
{jobDetailQuery.isLoading ? <p className="muted">…</p> : null}
|
||||
{jobDetailQuery.data?.error ? (
|
||||
<p className="muted" style={{ color: "var(--danger, #b00)" }}>
|
||||
{jobDetailQuery.data.error}
|
||||
</p>
|
||||
) : null}
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("lldpLinks.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.neighbors")}</th>
|
||||
<th>{t("lldpLinks.col.edges")}</th>
|
||||
<th>{t("lldpLinks.col.error")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailItems.map((it) => {
|
||||
const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
|
||||
return (
|
||||
<tr key={it.id}>
|
||||
<td>{it.ne_name || it.ne_id || "—"}</td>
|
||||
<td>{it.ne_ip || "—"}</td>
|
||||
<td>
|
||||
{!it.ok ? "fail" : it.parser_stub || unmatchedCount > 0 ? "warn" : "ok"}
|
||||
</td>
|
||||
<td>{it.neighbors}</td>
|
||||
<td>
|
||||
+{it.edges_added} / ~{it.edges_updated}
|
||||
{unmatchedCount > 0
|
||||
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
|
||||
: ""}
|
||||
</td>
|
||||
<td className="muted">{it.error || "—"}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setItemDetail(it)}
|
||||
>
|
||||
{t("topology.discoverViewDetail")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!detailItems.length && !jobDetailQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
{t("common.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{itemDetail ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setItemDetail(null)}>
|
||||
<div
|
||||
className="modal modal--wide topo-discover-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="lldp-item-detail-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="topo-discover-modal__head">
|
||||
<div>
|
||||
<h3 id="lldp-item-detail-title">
|
||||
{itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id}
|
||||
</h3>
|
||||
<p className="topo-discover-modal__sub">
|
||||
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setItemDetail(null)}
|
||||
>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!itemDetail.ok ? (
|
||||
<p className="topo-discover__error">
|
||||
{itemDetail.error || t("topology.discoverNeFail")}
|
||||
</p>
|
||||
) : null}
|
||||
{itemDetail.parser_stub ? (
|
||||
<p className="topo-discover__item-warn">
|
||||
{t("topology.discoverParserStub").replace(
|
||||
"{{parser}}",
|
||||
itemDetail.parser_key || "unknown",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="topo-discover-modal__stats">
|
||||
<span>
|
||||
{t("topology.discoverNeOk")
|
||||
.replace("{{neighbors}}", String(itemDetail.neighbors || 0))
|
||||
.replace("{{added}}", String(itemDetail.edges_added || 0))
|
||||
.replace("{{updated}}", String(itemDetail.edges_updated || 0))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="topo-discover-modal__section">
|
||||
{t("topology.discoverUnmatchedTitle").replace(
|
||||
"{{count}}",
|
||||
String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
|
||||
)}
|
||||
</h4>
|
||||
{(itemDetail.unmatched || []).length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
|
||||
) : (
|
||||
<div className="topo-discover-modal__table-wrap">
|
||||
<table className="topo-discover-modal__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("topology.discoverColRemote")}</th>
|
||||
<th>{t("topology.discoverColLocalPort")}</th>
|
||||
<th>{t("topology.discoverColRemotePort")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(itemDetail.unmatched || []).map((u, idx) => (
|
||||
<tr key={`${itemDetail.id}-u-${idx}`}>
|
||||
<td>
|
||||
{(u.remote_name || u.remote_ip || "?").trim()}
|
||||
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
|
||||
</td>
|
||||
<td>{u.local_port || "?"}</td>
|
||||
<td>{u.remote_port || "?"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{itemDetail.raw_preview ? (
|
||||
<>
|
||||
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
|
||||
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="modal__actions">
|
||||
<button type="button" className="btn btn--sm" onClick={() => setItemDetail(null)}>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,11 +18,13 @@ import type {
|
|||
CliMeta,
|
||||
CliTargetListResponse,
|
||||
UmeCliOverrideItem,
|
||||
TopologyDiscoverOut,
|
||||
TopologyDiscoverNeResult,
|
||||
TopologyDiscoverStreamHandlers,
|
||||
TopologyGraph,
|
||||
TopologyMapItem,
|
||||
FabricSummary,
|
||||
TopologyDiscoverJob,
|
||||
TopologyViewGraph,
|
||||
TopologyViewItem,
|
||||
LldpCollectDashboard,
|
||||
LldpCollectJobSummary,
|
||||
LldpCollectPolicy,
|
||||
ConfigSyncCycle,
|
||||
ConfigSyncDashboard,
|
||||
ConfigSyncPolicy,
|
||||
|
|
@ -846,158 +848,110 @@ export const fetchUmeCurrentAlarms = (params: {
|
|||
);
|
||||
};
|
||||
|
||||
export const fetchTopologyMaps = () =>
|
||||
apiGet<{ total: number; items: TopologyMapItem[] }>("/v1/topology/maps");
|
||||
export const fetchTopologyViews = () =>
|
||||
apiGet<{ total: number; items: TopologyViewItem[] }>("/v1/topology/views");
|
||||
|
||||
export const createTopologyMap = (body: { name: string; remark?: string }) =>
|
||||
apiPost<TopologyMapItem>("/v1/topology/maps", body);
|
||||
export const createTopologyView = (body: { name: string; remark?: string; filter?: Record<string, unknown> }) =>
|
||||
apiPost<TopologyViewItem>("/v1/topology/views", body);
|
||||
|
||||
export const fetchTopologyGraph = (mapId: string) =>
|
||||
apiGet<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}`);
|
||||
export const fetchTopologyViewGraph = (viewId: string) =>
|
||||
apiGet<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}`);
|
||||
|
||||
export const updateTopologyMap = (mapId: string, body: { name?: string; remark?: string }) =>
|
||||
apiPatch<TopologyMapItem>(`/v1/topology/maps/${encodeURIComponent(mapId)}`, body);
|
||||
export const updateTopologyView = (
|
||||
viewId: string,
|
||||
body: { name?: string; remark?: string; filter?: Record<string, unknown>; viewport?: Record<string, unknown> },
|
||||
) => apiPatch<TopologyViewItem>(`/v1/topology/views/${encodeURIComponent(viewId)}`, body);
|
||||
|
||||
export const deleteTopologyMap = (mapId: string) =>
|
||||
apiDelete<{ ok: boolean; map_id: string; deleted: boolean }>(
|
||||
`/v1/topology/maps/${encodeURIComponent(mapId)}`,
|
||||
export const deleteTopologyView = (viewId: string) =>
|
||||
apiDelete<{ ok: boolean; view_id: string; deleted: boolean }>(
|
||||
`/v1/topology/views/${encodeURIComponent(viewId)}`,
|
||||
);
|
||||
|
||||
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;
|
||||
stroke_color?: string;
|
||||
stroke_width?: number;
|
||||
line_style?: string;
|
||||
discovered_at?: string | null;
|
||||
}>;
|
||||
},
|
||||
) => apiPut<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body);
|
||||
|
||||
export const discoverTopologyNeighbors = (
|
||||
mapId: string,
|
||||
body?: { protocol?: string; ne_ids?: string[] },
|
||||
export const patchTopologyPositions = (
|
||||
viewId: string,
|
||||
positions: Array<{
|
||||
fabric_node_id: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
label?: string;
|
||||
locked?: boolean;
|
||||
}>,
|
||||
) =>
|
||||
apiPost<TopologyDiscoverOut>(
|
||||
`/v1/topology/maps/${encodeURIComponent(mapId)}/discover`,
|
||||
body || {},
|
||||
);
|
||||
|
||||
function parseSseChunks(buffer: string): { events: Array<{ event: string; data: string }>; rest: string } {
|
||||
const events: Array<{ event: string; data: string }> = [];
|
||||
let rest = buffer;
|
||||
while (true) {
|
||||
const sep = rest.indexOf("\n\n");
|
||||
if (sep < 0) break;
|
||||
const raw = rest.slice(0, sep);
|
||||
rest = rest.slice(sep + 2);
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (dataLines.length) events.push({ event, data: dataLines.join("\n") });
|
||||
}
|
||||
return { events, rest };
|
||||
}
|
||||
|
||||
export async function discoverTopologyNeighborsStream(
|
||||
mapId: string,
|
||||
body: { protocol?: string; ne_ids?: string[] } | undefined,
|
||||
handlers: TopologyDiscoverStreamHandlers,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TopologyDiscoverOut> {
|
||||
const path = `/v1/topology/maps/${encodeURIComponent(mapId)}/discover/stream`;
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: authHeaders({
|
||||
accept: "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify(body || {}),
|
||||
signal,
|
||||
apiPatch<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, {
|
||||
positions,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `discover_stream_failed:${res.status}`);
|
||||
}
|
||||
if (!res.body) throw new Error("discover_stream_empty_body");
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let finalReport: TopologyDiscoverOut | null = null;
|
||||
export const addTopologyViewNodes = (
|
||||
viewId: string,
|
||||
body: {
|
||||
managed_ne_ids?: string[];
|
||||
ume_ne_ids?: string[];
|
||||
fabric_node_ids?: string[];
|
||||
layout?: string;
|
||||
},
|
||||
) => apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parsed = parseSseChunks(buf);
|
||||
buf = parsed.rest;
|
||||
for (const item of parsed.events) {
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(item.data) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const type = String(payload.type || item.event || "");
|
||||
if (type === "start") {
|
||||
handlers.onStart?.({
|
||||
map_id: String(payload.map_id || ""),
|
||||
protocol: String(payload.protocol || ""),
|
||||
total: Number(payload.total || 0),
|
||||
});
|
||||
} else if (type === "ne_start") {
|
||||
handlers.onNeStart?.({
|
||||
index: Number(payload.index || 0),
|
||||
total: Number(payload.total || 0),
|
||||
ne_id: String(payload.ne_id || ""),
|
||||
ne_name: String(payload.ne_name || ""),
|
||||
ne_ip: String(payload.ne_ip || ""),
|
||||
});
|
||||
} else if (type === "ne_result") {
|
||||
handlers.onNeResult?.({
|
||||
index: Number(payload.index || 0),
|
||||
total: Number(payload.total || 0),
|
||||
result: payload.result as TopologyDiscoverNeResult,
|
||||
edges_added: Number(payload.edges_added || 0),
|
||||
edges_updated: Number(payload.edges_updated || 0),
|
||||
});
|
||||
} else if (type === "done") {
|
||||
finalReport = payload.report as TopologyDiscoverOut;
|
||||
if (finalReport) handlers.onDone?.(finalReport);
|
||||
} else if (type === "error") {
|
||||
const detail = String(payload.detail || "discover_stream_error");
|
||||
handlers.onError?.(detail);
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) =>
|
||||
apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, {
|
||||
fabric_node_ids: fabricNodeIds,
|
||||
});
|
||||
|
||||
if (!finalReport) throw new Error("discover_stream_incomplete");
|
||||
return finalReport;
|
||||
}
|
||||
export const projectTopologyNeighbors = (viewId: string) =>
|
||||
apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, {});
|
||||
|
||||
export const patchTopologyEdgeStyle = (
|
||||
viewId: string,
|
||||
body: { fabric_edge_id: string; stroke_color?: string; stroke_width?: number; line_style?: string },
|
||||
) =>
|
||||
apiPatch<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/edge-style`, body);
|
||||
|
||||
export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabric/summary");
|
||||
|
||||
export const startLldpDiscover = (body?: {
|
||||
scope?: "all_inventory" | "ne_ids";
|
||||
ne_ids?: string[];
|
||||
auto_add_unmatched?: boolean;
|
||||
concurrency?: number;
|
||||
trigger_mode?: "manual" | "schedule" | "topology";
|
||||
}) => apiPost<TopologyDiscoverJob>("/v1/topology/fabric/discover", body || {});
|
||||
|
||||
export const fetchLldpDiscoverJob = (jobId: string) =>
|
||||
apiGet<TopologyDiscoverJob>(`/v1/topology/fabric/discover/${encodeURIComponent(jobId)}`);
|
||||
|
||||
export const fetchLldpCollectDashboard = () =>
|
||||
apiGet<LldpCollectDashboard>("/v1/topology/lldp-collect/dashboard");
|
||||
|
||||
export const updateLldpCollectPolicy = (body: Partial<LldpCollectPolicy>) =>
|
||||
apiPut<LldpCollectPolicy>("/v1/topology/lldp-collect/policy", body);
|
||||
|
||||
export const startLldpCollect = () =>
|
||||
apiPost<{ ok: boolean; job: TopologyDiscoverJob }>("/v1/topology/lldp-collect/start", {});
|
||||
|
||||
export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: LldpCollectJobSummary[] }>(
|
||||
`/v1/topology/lldp-collect/jobs?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchLldpCollectJob = (jobId: string) =>
|
||||
apiGet<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}`);
|
||||
|
||||
export const createFabricManualEdge = (body: {
|
||||
a_node_id: string;
|
||||
b_node_id: string;
|
||||
a_port?: string;
|
||||
b_port?: string;
|
||||
}) => apiPost<{ ok: boolean; action: string; edge: Record<string, unknown> }>("/v1/topology/fabric/edges", body);
|
||||
|
||||
/** Back-compat aliases used by older call sites during cutover. */
|
||||
export const fetchTopologyMaps = fetchTopologyViews;
|
||||
export const createTopologyMap = createTopologyView;
|
||||
export const fetchTopologyGraph = fetchTopologyViewGraph;
|
||||
export const updateTopologyMap = updateTopologyView;
|
||||
export const deleteTopologyMap = deleteTopologyView;
|
||||
|
||||
export const fetchConfigSyncDashboard = () =>
|
||||
apiGet<ConfigSyncDashboard>("/v1/config-sync/dashboard");
|
||||
|
|
|
|||
163
web/src/types.ts
163
web/src/types.ts
|
|
@ -348,49 +348,62 @@ export type UmeTokenStatus = {
|
|||
error?: string;
|
||||
};
|
||||
|
||||
export type TopologyMapItem = {
|
||||
export type TopologyViewItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
remark: string;
|
||||
filter?: Record<string, unknown>;
|
||||
viewport?: Record<string, unknown>;
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TopologyNodeItem = {
|
||||
id: string;
|
||||
map_id: string;
|
||||
export type TopologyViewNodeItem = {
|
||||
fabric_node_id: string;
|
||||
managed_ne_id: string;
|
||||
ume_ne_id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
ne_name: string;
|
||||
ne_ip: string;
|
||||
locked: boolean;
|
||||
name: string;
|
||||
ip: string;
|
||||
vendor: string;
|
||||
protocol: string;
|
||||
device_type: string;
|
||||
connect_status: string;
|
||||
};
|
||||
|
||||
export type TopologyEdgeItem = {
|
||||
export type TopologyViewEdgeItem = {
|
||||
id: string;
|
||||
map_id: string;
|
||||
source_node_id: string;
|
||||
target_node_id: string;
|
||||
source_port: string;
|
||||
target_port: string;
|
||||
a_node_id: string;
|
||||
b_node_id: string;
|
||||
a_port: string;
|
||||
b_port: string;
|
||||
source: string;
|
||||
status: string;
|
||||
layer: string;
|
||||
stroke_color?: string;
|
||||
stroke_width?: number;
|
||||
line_style?: string;
|
||||
discovered_at?: string | null;
|
||||
};
|
||||
|
||||
export type TopologyGraph = {
|
||||
map: TopologyMapItem;
|
||||
nodes: TopologyNodeItem[];
|
||||
edges: TopologyEdgeItem[];
|
||||
export type TopologyViewGraph = {
|
||||
view: TopologyViewItem;
|
||||
nodes: TopologyViewNodeItem[];
|
||||
edges: TopologyViewEdgeItem[];
|
||||
truncated?: boolean;
|
||||
truncate_reason?: string;
|
||||
};
|
||||
|
||||
export type FabricSummary = {
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
edge_active: number;
|
||||
edge_stale: number;
|
||||
last_discover_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TopologyDiscoverUnmatched = {
|
||||
|
|
@ -400,19 +413,12 @@ export type TopologyDiscoverUnmatched = {
|
|||
remote_port: string;
|
||||
};
|
||||
|
||||
export type TopologyDiscoverLink = {
|
||||
peer_node_id?: string;
|
||||
peer_ne_id?: string;
|
||||
peer_name?: string;
|
||||
peer_ip?: string;
|
||||
local_port?: string;
|
||||
remote_port?: string;
|
||||
protocol?: string;
|
||||
action?: string;
|
||||
};
|
||||
|
||||
export type TopologyDiscoverNeResult = {
|
||||
export type TopologyDiscoverJobItem = {
|
||||
id: string;
|
||||
job_id: string;
|
||||
ne_id: string;
|
||||
ume_ne_id: string;
|
||||
fabric_node_id: string;
|
||||
ne_name: string;
|
||||
ne_ip: string;
|
||||
ok: boolean;
|
||||
|
|
@ -420,44 +426,53 @@ export type TopologyDiscoverNeResult = {
|
|||
neighbors: number;
|
||||
edges_added: number;
|
||||
edges_updated: number;
|
||||
unmatched_count?: number;
|
||||
unmatched_count: number;
|
||||
unmatched?: TopologyDiscoverUnmatched[];
|
||||
links?: TopologyDiscoverLink[];
|
||||
parser_key?: string;
|
||||
parser_stub?: boolean;
|
||||
error: string;
|
||||
raw_preview: string;
|
||||
};
|
||||
|
||||
export type TopologyDiscoverJob = {
|
||||
id: string;
|
||||
scope: string;
|
||||
trigger_mode?: string;
|
||||
status: string;
|
||||
total: number;
|
||||
done: number;
|
||||
edges_added: number;
|
||||
edges_updated: number;
|
||||
edges_stale: number;
|
||||
error: string;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
created_at?: string | null;
|
||||
items: TopologyDiscoverJobItem[];
|
||||
};
|
||||
|
||||
/** @deprecated alias — prefer TopologyViewItem */
|
||||
export type TopologyMapItem = TopologyViewItem;
|
||||
|
||||
/** UI-compat aliases for restored TopologyPage. */
|
||||
export type TopologyNodeItem = TopologyViewNodeItem & { id?: string; map_id?: string; ne_name?: string; ne_ip?: string; protocol?: string };
|
||||
export type TopologyEdgeItem = TopologyViewEdgeItem & {
|
||||
source_node_id?: string;
|
||||
target_node_id?: string;
|
||||
map_id?: string;
|
||||
};
|
||||
export type TopologyDiscoverNeResult = TopologyDiscoverJobItem & {
|
||||
links?: Array<Record<string, unknown>>;
|
||||
};
|
||||
export type TopologyDiscoverOut = {
|
||||
map_id: string;
|
||||
protocol: string;
|
||||
map_id?: string;
|
||||
protocol?: string;
|
||||
scanned: number;
|
||||
edges_added: number;
|
||||
edges_updated: number;
|
||||
edges_stale?: number;
|
||||
results: TopologyDiscoverNeResult[];
|
||||
graph: TopologyGraph | null;
|
||||
};
|
||||
|
||||
export type TopologyDiscoverStreamHandlers = {
|
||||
onStart?: (ev: { map_id: string; protocol: string; total: number }) => void;
|
||||
onNeStart?: (ev: {
|
||||
index: number;
|
||||
total: number;
|
||||
ne_id: string;
|
||||
ne_name: string;
|
||||
ne_ip: string;
|
||||
}) => void;
|
||||
onNeResult?: (ev: {
|
||||
index: number;
|
||||
total: number;
|
||||
result: TopologyDiscoverNeResult;
|
||||
edges_added: number;
|
||||
edges_updated: number;
|
||||
}) => void;
|
||||
onDone?: (report: TopologyDiscoverOut) => void;
|
||||
onError?: (detail: string) => void;
|
||||
graph: TopologyViewGraph | null;
|
||||
};
|
||||
|
||||
export type ConfigSyncTargetRef = {
|
||||
|
|
@ -465,6 +480,44 @@ export type ConfigSyncTargetRef = {
|
|||
id: string;
|
||||
};
|
||||
|
||||
export type LldpCollectPolicy = {
|
||||
enabled: boolean;
|
||||
interval_days: number;
|
||||
concurrency: number;
|
||||
scope_mode: string;
|
||||
selected_targets: ConfigSyncTargetRef[];
|
||||
auto_add_unmatched: boolean;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type LldpCollectJobSummary = {
|
||||
id: string;
|
||||
scope: string;
|
||||
trigger_mode: string;
|
||||
status: string;
|
||||
total: number;
|
||||
done: number;
|
||||
edges_added: number;
|
||||
edges_updated: number;
|
||||
edges_stale: number;
|
||||
error: string;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type LldpCollectDashboard = {
|
||||
policy: LldpCollectPolicy;
|
||||
fabric_node_count: number;
|
||||
fabric_edge_count: number;
|
||||
fabric_edge_active: number;
|
||||
fabric_edge_stale: number;
|
||||
last_discover_at?: string | null;
|
||||
running_job: LldpCollectJobSummary | null;
|
||||
last_job: LldpCollectJobSummary | null;
|
||||
next_due_at?: string | null;
|
||||
};
|
||||
|
||||
export type ConfigSyncPolicy = {
|
||||
enabled: boolean;
|
||||
interval_days: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue