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:
oliver 2026-08-02 03:39:15 +08:00
parent 9674de221d
commit d05ba0f1f7
30 changed files with 4713 additions and 1870 deletions

View file

@ -123,19 +123,14 @@ src/
- 采集日志:`GET /v1/port-traffic/devices/{id}/events`;失败写入 `port_traffic_event`,列表操作可查看
- 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元
## 拓扑管理
## 拓扑管理(Fabric + View)
- 渲染:`@xyflow/react`;布局:`dagre`(层次)+ 内置力导向/网格/环形
- 工具模式:选择(框选多选)/ 平移 / 拖动 / 连线;快捷键 `V` `H` `A` `C`
- 侧栏网元可点击或拖放到画布;发现默认保留节点坐标(「显示 → 发现后自动布局」可选开启并本机记住)
- 支持对齐、网格吸附、轻量撤销/重做、链路图例
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名
- 未保存切换地图会确认;Ctrl+S 保存;发现可取消;禁止自环与重复连线;PUT 保留 `discovered_at`;发现边键对端口名做规范化
- 发现进度主区只显示摘要与入口;点「详情」打开列表弹窗,再点设备打开链路复核弹窗
- 工具栏可搜索定位并高亮;侧栏已上图网元点击可定位
- 连线模式画布顶提示;侧栏折叠/编辑/删除使用内联 SVG 图标;名称与 IP/端口间隔统一为 ASCII ` / `(避免 Unicode 损坏)
- 链路右键可跳转端口流量(`/network/tasks/port-traffic?ne_id=&source=&ifname=`)
- 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
- 视图:`topo_view` + `topo_view_node`(坐标);旧 `topology_map/node/edge` 已退役
- API:`/v1/topology/fabric/*`(summary / nodes / edges / neighborhood / discover job)、`/v1/topology/views*`
- 前端:`@xyflow/react`;侧栏上图、拖拽保存坐标(PATCH positions)、布局(dagre/力导向/网格/环形)、轮询 LLDP job
- MCP:`getTopologySummary`、`queryTopologyNodes/Edges`、`getTopologyNeighborhood`、`runLldpDiscover`、`getLldpDiscoverJob`、`listTopologyViews`、`getTopologyView`
- BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO
## WebCRT

View file

@ -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 />} />

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

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

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

View file

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

View file

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