Stream topology discovery progress and mark missing links stale.

SSE updates the UI per NE; edges not seen in a successful scan turn red and can be cleared.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-30 00:35:16 +08:00
parent bfc10ee8cf
commit bf82a01a03
11 changed files with 748 additions and 67 deletions

View file

@ -611,17 +611,20 @@ const en = {
saved: "Topology saved",
discover: "Discover links",
discovering: "Discovering…",
discovered: "Discovery done: +{{added}} updated {{updated}}",
discovered: "Discovery done: +{{added}} updated {{updated}} missing {{stale}}",
discoverFail: "Discovery failed: {{detail}}",
selectMap: "Select or create a topology map",
canvasHint: "Add NEs from the left, drag and connect; dashed edges are LLDP/CDP",
canvasHint: "Add NEs from the left; blue dashed=found, red dashed=missing (deletable)",
selected: "Selected",
selectedEdge: "Selected edge",
openWebcrt: "Open terminal",
openNe: "NE details",
removeNode: "Remove from canvas",
removeEdge: "Delete edge",
noNeLink: "Not linked to a managed NE",
edgeManual: "Manual",
edgeDiscovered: "Discovered",
edgeStale: "Missing",
fit: "Fit view",
display: "Display",
hideIp: "Hide IP",
@ -637,6 +640,16 @@ const en = {
paletteManaged: "Managed",
paletteUme: "UME",
paletteLoading: "Loading…",
discoverReport: "Discovery progress",
discoverClose: "Close",
discoverProgress: "Scanning {{count}} managed NE(s) on the map (serial LLDP; please wait)…",
discoverProgressLive: "Scanning {{index}}/{{total}}: {{name}}",
discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}",
discoverNeOk: "neighbors {{neighbors}} · added {{added}} · updated {{updated}}",
discoverNeFail: "Collect failed",
removeStale: "Clear missing ({{count}})",
removeStaleHint: "Delete red-marked missing edges",
staleRemoved: "Removed {{count}} missing edge(s)",
},
};

View file

@ -609,17 +609,20 @@ const zh = {
saved: "拓扑已保存",
discover: "发现链路",
discovering: "发现中…",
discovered: "发现完成:新增 {{added}},更新 {{updated}}",
discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}",
discoverFail: "发现失败:{{detail}}",
selectMap: "请选择或新建一张拓扑图",
canvasHint: "从左侧添加网元,拖动节点并连线;虚线为 LLDP/CDP 发现链路",
canvasHint: "从左侧添加网元,拖动节点并连线;蓝虚线=发现链路,红虚线=本次未发现(可删)",
selected: "已选节点",
selectedEdge: "已选链路",
openWebcrt: "打开终端",
openNe: "网元详情",
removeNode: "从画布移除",
removeEdge: "删除链路",
noNeLink: "未关联托管网元",
edgeManual: "人工",
edgeDiscovered: "发现",
edgeStale: "未发现",
fit: "适应画布",
display: "显示选项",
hideIp: "隐藏 IP",
@ -635,6 +638,16 @@ const zh = {
paletteManaged: "托管网元",
paletteUme: "UME 网元",
paletteLoading: "加载中…",
discoverReport: "发现进度",
discoverClose: "关闭",
discoverProgress: "正在扫描画布上 {{count}} 台托管网元(串行登录采集 LLDP,请稍候)…",
discoverProgressLive: "正在扫描 {{index}}/{{total}}:{{name}}",
discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}",
discoverNeOk: "邻居 {{neighbors}} · 新增 {{added}} · 更新 {{updated}}",
discoverNeFail: "采集失败",
removeStale: "清除未发现 ({{count}})",
removeStaleHint: "删除红色标记的未发现链路",
staleRemoved: "已删除 {{count}} 条未发现链路",
},
};

View file

@ -2089,6 +2089,88 @@ pre {
gap: 8px;
}
.topo-discover {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 220px;
padding: 10px 12px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
overflow: hidden;
}
.topo-discover__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.topo-discover__summary {
margin: 0;
font-size: 13px;
color: #334155;
}
.topo-discover__error {
margin: 0;
font-size: 13px;
color: #b91c1c;
}
.topo-discover__list {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
flex: 1;
min-height: 0;
}
.topo-discover__list li {
padding: 8px;
border-radius: 8px;
margin-bottom: 4px;
background: #fff;
border: 1px solid #e2e8f0;
}
.topo-discover__list li.is-fail {
border-color: #fecaca;
background: #fff1f2;
}
.topo-discover__item-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-weight: 600;
color: #0f172a;
}
.topo-discover__badge {
font-size: 11px;
font-weight: 700;
color: #166534;
}
.topo-discover__list li.is-fail .topo-discover__badge {
color: #b91c1c;
}
.topo-discover__item-meta,
.topo-discover__item-cmd {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
.topo-discover__item-cmd {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.topo-canvas {
position: relative;
flex: 1;

View file

@ -22,7 +22,7 @@ import "@xyflow/react/dist/style.css";
import {
createTopologyMap,
deleteTopologyMap,
discoverTopologyNeighbors,
discoverTopologyNeighborsStream,
fetchManagedNe,
fetchTopologyGraph,
fetchTopologyMaps,
@ -34,7 +34,14 @@ import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows";
import type { ManagedNeItem, TopologyEdgeItem, TopologyNodeItem, UmeNeItem } from "../types";
import type {
ManagedNeItem,
TopologyDiscoverNeResult,
TopologyDiscoverOut,
TopologyEdgeItem,
TopologyNodeItem,
UmeNeItem,
} from "../types";
type NeNodeData = {
label: string;
@ -140,6 +147,17 @@ function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
const nodeTypes = { neNode: NeNode };
function edgeStyle(source: string): { stroke: string; strokeDasharray?: string; strokeWidth?: number } {
const src = (source || "manual").toLowerCase();
if (src === "stale") {
return { stroke: "#dc2626", strokeDasharray: "4 4", strokeWidth: 2 };
}
if (src === "lldp" || src === "cdp") {
return { stroke: "#0ea5e9", strokeDasharray: "6 4" };
}
return { stroke: "#64748b" };
}
function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) {
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => ({
id: n.id,
@ -155,7 +173,7 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) {
},
}));
const rfEdges: Edge[] = edges.map((e) => {
const discovered = e.source === "lldp" || e.source === "cdp";
const src = e.source || "manual";
const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ ");
return {
id: e.id,
@ -164,12 +182,10 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) {
type: "straight",
label: label || undefined,
animated: false,
style: discovered
? { stroke: "#0ea5e9", strokeDasharray: "6 4" }
: { stroke: "#64748b" },
style: edgeStyle(src),
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
data: {
source: e.source || "manual",
source: src,
source_port: e.source_port || "",
target_port: e.target_port || "",
},
@ -206,6 +222,7 @@ export function TopologyPage() {
const [mapId, setMapId] = useState<string>("");
const [keyword, setKeyword] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [hideIp, setHideIp] = useState(true);
const [hideVendor, setHideVendor] = useState(true);
const [hidePorts, setHidePorts] = useState(true);
@ -213,6 +230,20 @@ export function TopologyPage() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [hideAddedNes, setHideAddedNes] = useState(true);
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
const [discoverOpen, setDiscoverOpen] = useState(false);
const [discovering, setDiscovering] = useState(false);
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
const [discoverLiveResults, setDiscoverLiveResults] = useState<TopologyDiscoverNeResult[]>([]);
const [discoverProgress, setDiscoverProgress] = useState({
index: 0,
total: 0,
neName: "",
neIp: "",
edgesAdded: 0,
edgesUpdated: 0,
});
const [discoverError, setDiscoverError] = useState("");
const discoverAbortRef = useRef<AbortController | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
@ -338,14 +369,60 @@ export function TopologyPage() {
onError: (err) => showError(String(err)),
});
const discoverMut = useMutation({
mutationFn: async () => {
const runDiscover = useCallback(async () => {
if (!mapId || discovering) return;
discoverAbortRef.current?.abort();
const ac = new AbortController();
discoverAbortRef.current = ac;
setDiscoverOpen(true);
setDiscovering(true);
setDiscoverReport(null);
setDiscoverLiveResults([]);
setDiscoverError("");
setDiscoverProgress({
index: 0,
total: nodes.filter((n) => Boolean(n.data.managed_ne_id)).length,
neName: "",
neIp: "",
edgesAdded: 0,
edgesUpdated: 0,
});
try {
if (dirtyRef.current) {
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
dirtyRef.current = false;
}
return discoverTopologyNeighbors(mapId, { protocol: "auto" });
},
onSuccess: async (out) => {
const out = await discoverTopologyNeighborsStream(
mapId,
{ protocol: "auto" },
{
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);
const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges);
@ -353,16 +430,42 @@ export function TopologyPage() {
setEdges(rfEdges);
dirtyRef.current = false;
}
setDiscoverReport(out);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(
t("topology.discovered")
.replace("{{added}}", String(out.edges_added))
.replace("{{updated}}", String(out.edges_updated)),
.replace("{{updated}}", String(out.edges_updated))
.replace("{{stale}}", String(out.edges_stale || 0)),
);
},
onError: (err) =>
showError(t("topology.discoverFail").replace("{{detail}}", String(err))),
});
} 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]);
const discoverResults = discoverReport?.results?.length
? discoverReport.results
: discoverLiveResults;
const discoverSummary = discoverReport
? {
scanned: discoverReport.scanned,
added: discoverReport.edges_added,
updated: discoverReport.edges_updated,
stale: discoverReport.edges_stale || 0,
failed: discoverReport.results.filter((r) => !r.ok).length,
}
: {
scanned: discoverLiveResults.length,
added: discoverProgress.edgesAdded,
updated: discoverProgress.edgesUpdated,
stale: 0,
failed: discoverLiveResults.filter((r) => !r.ok).length,
};
const onConnect = useCallback(
(connection: Connection) => {
@ -455,8 +558,24 @@ export function TopologyPage() {
() => nodes.find((n) => n.id === selectedNodeId) || null,
[nodes, selectedNodeId],
);
const selectedEdge = useMemo(
() => edges.find((e) => e.id === selectedEdgeId) || null,
[edges, selectedEdgeId],
);
const staleEdgeCount = useMemo(
() =>
edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale")
.length,
[edges],
);
const removeSelected = () => {
if (selectedEdgeId) {
dirtyRef.current = true;
setEdges((es) => es.filter((e) => e.id !== selectedEdgeId));
setSelectedEdgeId(null);
return;
}
if (!selectedNodeId) return;
dirtyRef.current = true;
setNodes((ns) => ns.filter((n) => n.id !== selectedNodeId));
@ -464,6 +583,17 @@ export function TopologyPage() {
setSelectedNodeId(null);
};
const removeStaleEdges = () => {
const n = staleEdgeCount;
if (n <= 0) return;
dirtyRef.current = true;
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 openWebcrt = () => {
const managedId = selectedNode?.data.managed_ne_id;
const umeId = selectedNode?.data.ume_ne_id;
@ -790,10 +920,19 @@ export function TopologyPage() {
<button
type="button"
className="btn btn--sm"
disabled={!mapId || discoverMut.isPending}
onClick={() => discoverMut.mutate()}
disabled={!mapId || discovering}
onClick={() => void runDiscover()}
>
{discoverMut.isPending ? t("topology.discovering") : t("topology.discover")}
{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"
@ -824,6 +963,83 @@ export function TopologyPage() {
</button>
</div>
</div>
) : selectedEdge ? (
<div className="topo-selection">
<span>
{t("topology.selectedEdge")}:{" "}
<strong>
{String((selectedEdge.data as { source?: string } | undefined)?.source || "manual")}
</strong>
{selectedEdge.label ? ` · ${String(selectedEdge.label)}` : ""}
</span>
<div className="topo-selection__actions">
<button type="button" className="btn btn--sm btn--ghost" onClick={removeSelected}>
{t("topology.removeEdge")}
</button>
</div>
</div>
) : null}
{discoverOpen ? (
<div className="topo-discover">
<div className="topo-discover__head">
<strong>{t("topology.discoverReport")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setDiscoverOpen(false)}
disabled={discovering}
>
{t("topology.discoverClose")}
</button>
</div>
{discovering ? (
<p className="panel__hint panel__hint--live">
{t("topology.discoverProgressLive")
.replace("{{index}}", String(discoverProgress.index))
.replace("{{total}}", String(discoverProgress.total))
.replace(
"{{name}}",
discoverProgress.neName || discoverProgress.neIp || "…",
)}
</p>
) : null}
{discoverError ? <p className="topo-discover__error">{discoverError}</p> : null}
{discoverResults.length > 0 || discoverReport ? (
<>
<p className="topo-discover__summary">
{t("topology.discoverSummary")
.replace("{{scanned}}", String(discoverSummary.scanned))
.replace("{{added}}", String(discoverSummary.added))
.replace("{{updated}}", String(discoverSummary.updated))
.replace("{{stale}}", String(discoverSummary.stale))
.replace("{{failed}}", String(discoverSummary.failed))}
</p>
<ul className="topo-discover__list">
{discoverResults.map((r) => (
<li key={r.ne_id} className={r.ok ? "is-ok" : "is-fail"}>
<div className="topo-discover__item-title">
<span>{r.ne_name || r.ne_ip || r.ne_id}</span>
<span className="topo-discover__badge">{r.ok ? "OK" : "FAIL"}</span>
</div>
<div className="topo-discover__item-meta">
{r.ne_ip ? `${r.ne_ip} · ` : ""}
{r.ok
? t("topology.discoverNeOk")
.replace("{{neighbors}}", String(r.neighbors))
.replace("{{added}}", String(r.edges_added))
.replace("{{updated}}", String(r.edges_updated))
: r.error || t("topology.discoverNeFail")}
</div>
{r.command ? (
<div className="topo-discover__item-cmd">{r.command}</div>
) : null}
</li>
))}
</ul>
</>
) : null}
</div>
) : null}
<div className="topo-canvas">
@ -844,13 +1060,25 @@ export function TopologyPage() {
onEdgesChange(changes);
}}
onConnect={onConnect}
onNodeClick={(_e, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)}
onNodeClick={(_e, node) => {
setSelectedEdgeId(null);
setSelectedNodeId(node.id);
}}
onEdgeClick={(_e, edge) => {
setSelectedNodeId(null);
setSelectedEdgeId(edge.id);
}}
onPaneClick={() => {
setSelectedNodeId(null);
setSelectedEdgeId(null);
}}
onInit={(inst) => {
rfRef.current = inst;
}}
fitView
deleteKeyCode={["Backspace", "Delete"]}
edgesFocusable
elementsSelectable
>
<Background gap={18} size={1} />
<Controls />

View file

@ -19,6 +19,8 @@ import type {
CliTargetListResponse,
UmeCliOverrideItem,
TopologyDiscoverOut,
TopologyDiscoverNeResult,
TopologyDiscoverStreamHandlers,
TopologyGraph,
TopologyMapItem,
} from "../types";
@ -473,3 +475,99 @@ export const discoverTopologyNeighbors = (
`/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 res = await fetch(`/v1/topology/maps/${encodeURIComponent(mapId)}/discover/stream`, {
method: "POST",
headers: {
accept: "text/event-stream",
"content-type": "application/json",
},
body: JSON.stringify(body || {}),
signal,
});
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;
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);
}
}
}
if (!finalReport) throw new Error("discover_stream_incomplete");
return finalReport;
}

View file

@ -405,6 +405,27 @@ export type TopologyDiscoverOut = {
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;
};