mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 21:53:22 +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
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue