Improve topology discovery UX with review modals and live search.

Report unmatched/stub neighbors and matched links, move result lists into detail dialogs, deep-link edges to port traffic, and add fuzzy type-ahead canvas/palette NE search.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 13:35:52 +08:00
parent 2382cb8269
commit 8cf1696d8c
12 changed files with 1442 additions and 66 deletions

View file

@ -116,6 +116,7 @@ src/
- 单飞:同一任务同时只允许一轮采集;崩溃启动清除 `collect_running`
- 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口)
- 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元
## 拓扑管理
@ -126,6 +127,9 @@ src/
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名
- 未保存切换地图会确认;Ctrl+S 保存;发现可取消;禁止自环与重复连线;PUT 保留 `discovered_at`;发现边键对端口名做规范化
- 发现进度主区只显示摘要与入口;点「详情」打开列表弹窗,再点设备打开链路复核弹窗
- 工具栏可搜索定位并高亮;侧栏已上图网元点击可定位
- 链路右键可跳转端口流量(`/network/tasks/port-traffic?ne_id=&source=&ifname=`)
## WebCRT

View file

@ -95,6 +95,8 @@ const en = {
concurrency: "Concurrency",
startNow: "Start after create",
neKeywordPh: "Name / IP / vendor",
deepLinkHint: "Opened from topology: NE {{ne}}{{ifname}}. Confirm selection, then continue.",
deepLinkIfname: ", interface {{ifname}}",
selectedNe: "{{count}} NE(s) selected",
selectedPorts: "{{count}} interface(s) selected",
pickNeFirst: "Select NEs in the previous step first",
@ -889,7 +891,7 @@ const en = {
emptyMaps: "No maps yet. Create one to start.",
palette: "NE library",
paletteHint: "Click or drag onto the canvas",
filterPh: "Filter name / IP",
filterPh: "Type to filter: name / IP / vendor",
save: "Save",
saveDirty: "Save *",
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
@ -985,8 +987,45 @@ const en = {
discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}",
discoverNeOk: "neighbors {{neighbors}} · added {{added}} · updated {{updated}}",
discoverNeFail: "Collect failed",
discoverUnmatched: "unmatched {{count}}",
discoverUnmatchedMore: "{{count}} more not listed",
discoverParserStub: "Parser \"{{parser}}\" is still a stub; output was collected but neighbors were not parsed",
discoverNoTargets:
"No scannable NEs on the map. Add managed NEs, or UME NEs with a CLI connect profile.",
discoverFilter: "Result filter",
discoverFilterIssues: "Issues",
discoverFilterFail: "Failed",
discoverFilterWarn: "Warnings",
discoverFilterOk: "OK",
discoverFilterAll: "All",
discoverFilterDetail: "Details",
discoverListTitle: "Discovery details",
discoverListEmpty: "No NEs under the current filter.",
discoverShowLimited: "Showing first {{shown}} / {{total}} (narrow with filters)",
discoverViewDetail: "Detail",
discoverLinkCount: "{{count}} link(s)",
discoverLinksTitle: "Matched links ({{count}})",
discoverLinksEmpty: "No on-map peer links matched in this scan.",
discoverUnmatchedTitle: "Unmatched neighbors ({{count}})",
discoverUnmatchedEmpty: "No unmatched neighbors.",
discoverColPeer: "Peer NE",
discoverColRemote: "Neighbor",
discoverColLocalPort: "Local port",
discoverColRemotePort: "Remote port",
discoverColProtocol: "Protocol",
discoverColAction: "Result",
discoverLinkAdded: "Added",
discoverLinkUpdated: "Updated",
discoverLinkKeptManual: "Kept manual",
discoverLocatePeer: "Locate peer",
discoverLocateNe: "Locate NE",
discoverRawPreview: "Raw output preview (truncated when long; parsing uses full output)",
findNode: "Find",
findNodePh: "Type to search: name / IP / vendor",
findNoMatch: "No matching node on the canvas",
findMore: "{{count}} more matches…",
locateOnCanvas: "Click to locate on canvas",
openPortTraffic: "Open port traffic",
removeStale: "Clear missing ({{count}})",
removeStaleHint: "Delete red-marked missing edges",
staleRemoved: "Removed {{count}} missing edge(s)",

View file

@ -95,6 +95,8 @@ const zh = {
concurrency: "并发",
startNow: "创建后立即启动",
neKeywordPh: "名称 / IP / 厂商",
deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续创建监控任务。",
deepLinkIfname: ",接口 {{ifname}}",
selectedNe: "已选 {{count}} 台网元",
selectedPorts: "已选 {{count}} 个接口",
pickNeFirst: "请先在上一步选择网元",
@ -886,7 +888,7 @@ const zh = {
emptyMaps: "还没有拓扑图,先新建一张。",
palette: "网元库",
paletteHint: "点击或拖到画布添加",
filterPh: "筛选名称 / IP",
filterPh: "输入即筛:名称 / IP / 厂商",
save: "保存",
saveDirty: "保存 *",
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
@ -982,7 +984,44 @@ const zh = {
discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}",
discoverNeOk: "邻居 {{neighbors}} · 新增 {{added}} · 更新 {{updated}}",
discoverNeFail: "采集失败",
discoverUnmatched: "未匹配 {{count}}",
discoverUnmatchedMore: "另有 {{count}} 条未列出",
discoverParserStub: "厂商解析器「{{parser}}」仍为 stub,已采集到输出但未能解析邻居",
discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。",
discoverFilter: "结果筛选",
discoverFilterIssues: "问题",
discoverFilterFail: "失败",
discoverFilterWarn: "警告",
discoverFilterOk: "成功",
discoverFilterAll: "全部",
discoverFilterDetail: "详情",
discoverListTitle: "发现详情",
discoverListEmpty: "当前筛选下没有网元。",
discoverShowLimited: "仅显示前 {{shown}} / {{total}} 条(请用筛选缩小范围)",
discoverViewDetail: "详情",
discoverLinkCount: "链路 {{count}}",
discoverLinksTitle: "匹配链路({{count}})",
discoverLinksEmpty: "本次未匹配到画布上的对端网元链路。",
discoverUnmatchedTitle: "未匹配邻居({{count}})",
discoverUnmatchedEmpty: "无未匹配邻居。",
discoverColPeer: "对端网元",
discoverColRemote: "邻居",
discoverColLocalPort: "本端端口",
discoverColRemotePort: "对端端口",
discoverColProtocol: "协议",
discoverColAction: "结果",
discoverLinkAdded: "新增",
discoverLinkUpdated: "更新",
discoverLinkKeptManual: "保留人工",
discoverLocatePeer: "定位对端",
discoverLocateNe: "定位本端",
discoverRawPreview: "采集原文预览(过长会截断,不影响解析)",
findNode: "定位",
findNodePh: "输入即搜:名称 / IP / 厂商",
findNoMatch: "画布上没有匹配的节点",
findMore: "另有 {{count}} 条匹配…",
locateOnCanvas: "点击定位到画布",
openPortTraffic: "打开端口流量",
removeStale: "清除未发现 ({{count}})",
removeStaleHint: "删除红色标记的未发现链路",
staleRemoved: "已删除 {{count}} 条未发现链路",

View file

@ -2265,6 +2265,79 @@ pre {
border-left: 1px solid #e2e8f0;
}
.topo-toolbar__group--find {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.topo-find-suggest {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 30;
width: min(320px, 70vw);
max-height: 280px;
overflow: auto;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 8px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
}
.topo-find-suggest__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: 8px 10px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.topo-find-suggest__item:hover,
.topo-find-suggest__item.is-active {
background: #eff6ff;
}
.topo-find-suggest__name {
font-size: 12px;
font-weight: 600;
color: #0f172a;
}
.topo-find-suggest__meta {
font-size: 11px;
color: #64748b;
}
.topo-find-suggest__empty,
.topo-find-suggest__more {
padding: 8px 10px;
font-size: 12px;
color: #94a3b8;
}
.topo-toolbar__find {
width: 150px;
max-width: 28vw;
height: 30px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 12px;
background: #fff;
}
.topo-toolbar__find:disabled {
opacity: 0.55;
}
.topo-toolbar__title {
display: flex;
align-items: center;
@ -2653,7 +2726,7 @@ pre {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 220px;
max-height: none;
padding: 10px 12px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
@ -2667,12 +2740,106 @@ pre {
gap: 8px;
}
.topo-discover__progress {
display: flex;
flex-direction: column;
gap: 4px;
}
.topo-discover__bar {
height: 6px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.topo-discover__bar-fill {
height: 100%;
background: #2563eb;
transition: width 0.2s ease;
}
.topo-discover__progress-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #475569;
}
.topo-discover__summary {
margin: 0;
font-size: 13px;
color: #334155;
}
.topo-discover__filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.topo-discover__filter {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font-size: 12px;
color: #475569;
cursor: pointer;
}
.topo-discover__filter.is-active {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
.topo-discover__filter--primary {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.topo-discover__filter--primary .topo-discover__filter-count {
color: #fff;
}
.topo-discover__filter-count {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.topo-discover__empty-ok {
margin: 0;
font-size: 12px;
color: #166534;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.topo-discover__link {
border: 0;
background: none;
padding: 0;
color: #2563eb;
cursor: pointer;
font-size: 12px;
text-decoration: underline;
}
.topo-discover__cap {
margin: 0;
font-size: 11px;
color: #94a3b8;
}
.topo-discover__error {
margin: 0;
font-size: 13px;
@ -2688,49 +2855,273 @@ pre {
min-height: 0;
}
.topo-discover__list li {
padding: 8px;
border-radius: 8px;
margin-bottom: 4px;
background: #fff;
.topo-discover__list--modal {
max-height: min(52vh, 480px);
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 4px;
background: #f8fafc;
}
.topo-discover__list li.is-fail {
.topo-discover-list-modal {
width: min(880px, 100%);
}
.topo-discover__list > li {
margin-bottom: 2px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.topo-discover__list > li.is-fail {
border-color: #fecaca;
background: #fff1f2;
}
.topo-discover__item-title {
.topo-discover__list > li.is-warn {
border-color: #fde68a;
background: #fffbeb;
}
.topo-discover__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 5px 8px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.topo-discover__row-main {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex: 1;
flex-wrap: wrap;
}
.topo-discover__row-name {
font-weight: 600;
font-size: 12px;
color: #0f172a;
}
.topo-discover__row-ip {
font-size: 12px;
color: #64748b;
font-variant-numeric: tabular-nums;
}
.topo-discover__row-meta {
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.topo-discover__badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
color: #166534;
}
.topo-discover__list li.is-fail .topo-discover__badge {
.topo-discover__list > li.is-fail .topo-discover__badge {
color: #b91c1c;
}
.topo-discover__list > li.is-warn .topo-discover__badge {
color: #b45309;
}
.topo-discover__detail {
padding: 0 8px 6px;
border-top: 1px dashed #e2e8f0;
}
.topo-discover__item-meta,
.topo-discover__item-cmd {
.topo-discover__item-cmd,
.topo-discover__item-warn {
margin-top: 2px;
font-size: 12px;
color: #64748b;
}
.topo-discover__item-cmd {
.topo-discover__item-warn {
color: #b45309;
}
.topo-discover__unmatched {
margin: 6px 0 0;
padding-left: 1.1rem;
font-size: 11px;
color: #92400e;
}
.topo-discover__unmatched-more {
list-style: none;
margin-left: -1.1rem;
color: #a16207;
}
.topo-discover__row-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.topo-discover__detail-btn {
font-size: 11px;
color: #2563eb;
white-space: nowrap;
}
.topo-discover__badge--lg {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
background: #ecfdf5;
}
.topo-discover__badge--lg.is-fail {
background: #fff1f2;
color: #b91c1c;
}
.topo-discover__badge--lg.is-warn {
background: #fffbeb;
color: #b45309;
}
.topo-discover-modal {
display: block;
width: min(960px, 100%);
max-height: min(88vh, 900px);
overflow: auto;
}
.topo-discover-modal__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.topo-discover-modal__head h3 {
margin: 0;
}
.topo-discover-modal__sub {
margin: 4px 0 0;
font-size: 13px;
color: #64748b;
}
.topo-discover-modal__stats {
margin-top: 8px;
font-size: 13px;
color: #334155;
}
.topo-discover-modal__section {
margin: 14px 0 8px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.topo-discover-modal__table-wrap {
overflow-x: auto;
overflow-y: visible;
max-height: none;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.topo-discover-modal__table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: auto;
}
.topo-discover-modal__table th,
.topo-discover-modal__table td {
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
vertical-align: top;
color: #0f172a;
background: #fff;
}
.topo-discover-modal__table th {
position: static;
background: #f8fafc;
color: #475569;
font-weight: 600;
white-space: nowrap;
}
.topo-discover-modal__table tbody tr:last-child td {
border-bottom: 0;
}
.topo-discover-modal__peer {
display: flex;
flex-direction: column;
gap: 2px;
}
.topo-discover-modal__peer span {
color: #64748b;
}
.topo-discover-action.is-added {
color: #166534;
font-weight: 600;
}
.topo-discover-action.is-updated {
color: #1d4ed8;
font-weight: 600;
}
.topo-discover-action.is-kept_manual {
color: #a16207;
font-weight: 600;
}
.topo-discover-modal__raw {
margin: 0;
padding: 10px 12px;
max-height: min(36vh, 360px);
overflow: auto;
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.topo-discover-list-modal .topo-discover__list--modal {
max-height: min(52vh, 480px);
overflow: auto;
}
.topo-canvas {
position: relative;
flex: 1;
@ -2812,6 +3203,22 @@ pre {
margin: 0 auto;
}
.react-flow__node.is-search-hit .topo-node__glyph {
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.85);
border-radius: 10px;
animation: topo-search-pulse 0.85s ease 2;
}
@keyframes topo-search-pulse {
0%,
100% {
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.85);
}
50% {
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.25);
}
}
.topo-node__handle {
width: 10px !important;
height: 10px !important;

View file

@ -137,6 +137,45 @@ function vendorTone(vendor: string): string {
return "other";
}
function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" {
if (!r.ok) return "fail";
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
if (r.parser_stub || unmatchedCount > 0) return "warn";
return "ok";
}
function normalizeSearchText(s: string): string {
return String(s || "")
.toLowerCase()
.replace(/[\s_\-./]+/g, "");
}
/** Case-insensitive substring + loose subsequence (e.g. r1 ? router-1). */
function fuzzyIncludes(haystack: string, needle: string): boolean {
const h = normalizeSearchText(haystack);
const n = normalizeSearchText(needle);
if (!n) return true;
if (!h) return false;
if (h.includes(n)) return true;
let i = 0;
for (const ch of h) {
if (ch === n[i]) i += 1;
if (i >= n.length) return true;
}
return false;
}
function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
const tokens = String(query || "")
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (!tokens.length) return false;
const bits = [n.data.label, n.data.ne_ip, n.data.vendor, n.data.managed_ne_id, n.data.ume_ne_id];
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
}
/** Asset: /topo/ne-router.png (default blue = ZTE); other vendors tint via CSS. */
function RouterIcon() {
return (
@ -410,7 +449,18 @@ export function TopologyPage() {
});
const [discoverError, setDiscoverError] = useState("");
const [fullscreen, setFullscreen] = useState(false);
const [canvasQuery, setCanvasQuery] = useState("");
const [searchHitIds, setSearchHitIds] = useState<string[]>([]);
const [findOpen, setFindOpen] = useState(false);
const [findActiveIdx, setFindActiveIdx] = useState(0);
const [discoverFilter, setDiscoverFilter] = useState<"issues" | "fail" | "warn" | "ok" | "all">(
"issues",
);
const [discoverDetail, setDiscoverDetail] = useState<TopologyDiscoverNeResult | null>(null);
const [discoverListOpen, setDiscoverListOpen] = useState(false);
const discoverAbortRef = useRef<AbortController | null>(null);
const searchHitTimerRef = useRef<number | null>(null);
const findBoxRef = useRef<HTMLDivElement | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
@ -452,7 +502,7 @@ export function TopologyPage() {
);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300);
const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 150);
return () => window.clearTimeout(timer);
}, [keyword]);
@ -675,7 +725,7 @@ export function TopologyPage() {
clearDirty();
setMapId(row.id);
showOk(t("topology.newMap"));
// Prompt rename right after create — default name is a placeholder.
// Prompt rename right after create ? default name is a placeholder.
window.setTimeout(() => promptRenameMap(row.id, row.name), 0);
},
onError: (err) => showError(String(err)),
@ -718,6 +768,9 @@ export function TopologyPage() {
setDiscoverReport(null);
setDiscoverLiveResults([]);
setDiscoverError("");
setDiscoverFilter("issues");
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(
@ -817,21 +870,49 @@ export function TopologyPage() {
const discoverResults = discoverReport?.results?.length
? discoverReport.results
: discoverLiveResults;
const discoverCounts = useMemo(() => {
let ok = 0;
let warn = 0;
let fail = 0;
for (const r of discoverResults) {
const k = discoverResultKind(r);
if (k === "fail") fail += 1;
else if (k === "warn") warn += 1;
else ok += 1;
}
return { ok, warn, fail, issues: warn + fail };
}, [discoverResults]);
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,
failed: discoverCounts.fail,
}
: {
scanned: discoverLiveResults.length,
added: discoverProgress.edgesAdded,
updated: discoverProgress.edgesUpdated,
stale: 0,
failed: discoverLiveResults.filter((r) => !r.ok).length,
failed: discoverCounts.fail,
};
const discoverFiltered = useMemo(() => {
return discoverResults.filter((r) => {
const k = discoverResultKind(r);
if (discoverFilter === "all") return true;
if (discoverFilter === "issues") return k === "fail" || k === "warn";
return k === discoverFilter;
});
}, [discoverResults, discoverFilter]);
const DISCOVER_LIST_CAP = 200;
const discoverVisible = discoverFiltered.slice(0, DISCOVER_LIST_CAP);
const discoverPct =
discovering && discoverProgress.total > 0
? Math.min(100, Math.round((discoverProgress.index / discoverProgress.total) * 100))
: discoverReport
? 100
: 0;
const isValidConnection = useCallback(
(connection: Connection | Edge) => {
@ -1256,6 +1337,38 @@ export function TopologyPage() {
showError(t("topology.noNeLink"));
};
const openPortTrafficForEdge = (edge: Edge | null) => {
closeCtxMenu();
if (!edge) {
showError(t("topology.noNeLink"));
return;
}
const srcNode = nodes.find((n) => n.id === edge.source);
const data = (edge.data || {}) as EdgeStyleData;
const ifname = String(data.source_port || "").trim();
const managedId = String(srcNode?.data.managed_ne_id || "").trim();
const umeId = String(srcNode?.data.ume_ne_id || "").trim();
if (managedId) {
const q = new URLSearchParams({ ne_id: managedId, source: "managed" });
if (ifname) q.set("ifname", ifname);
openOrFocusModule({
moduleId: "network",
path: `/network/tasks/port-traffic?${q.toString()}`,
});
return;
}
if (umeId) {
const q = new URLSearchParams({ ne_id: umeId, source: "ume" });
if (ifname) q.set("ifname", ifname);
openOrFocusModule({
moduleId: "network",
path: `/network/tasks/port-traffic?${q.toString()}`,
});
return;
}
showError(t("topology.noNeLink"));
};
const discoverOneFor = (node: Node<NeNodeData> | null) => {
closeCtxMenu();
const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim();
@ -1292,6 +1405,83 @@ export function TopologyPage() {
[setNodes, setEdges],
);
const locateNode = useCallback(
(nodeId: string) => {
focusNode(nodeId, false);
setSearchHitIds([nodeId]);
if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current);
window.setTimeout(() => {
rfRef.current?.fitView({ nodes: [{ id: nodeId }], padding: 0.45, duration: 280 });
}, 30);
searchHitTimerRef.current = window.setTimeout(() => {
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
searchHitTimerRef.current = null;
}, 2200);
},
[focusNode],
);
const canvasHits = useMemo(() => {
const q = canvasQuery.trim();
if (!q) return [];
return nodes.filter((n) => nodeMatchesQuery(n, q));
}, [canvasQuery, nodes]);
useEffect(() => {
const q = canvasQuery.trim();
if (!q) {
setSearchHitIds([]);
setFindActiveIdx(0);
setFindOpen(false);
return;
}
setFindOpen(true);
setSearchHitIds(canvasHits.map((n) => n.id));
setFindActiveIdx((idx) => (canvasHits.length ? Math.min(idx, canvasHits.length - 1) : 0));
}, [canvasQuery, canvasHits]);
const findOnCanvas = useCallback(
(nodeId?: string) => {
const q = canvasQuery.trim();
if (!q) return;
if (!canvasHits.length) {
showError(t("topology.findNoMatch"));
return;
}
const id =
nodeId ||
canvasHits[Math.max(0, Math.min(findActiveIdx, canvasHits.length - 1))]?.id ||
canvasHits[0].id;
setFindOpen(false);
locateNode(id);
},
[canvasQuery, canvasHits, findActiveIdx, locateNode, showError, t],
);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (!findBoxRef.current) return;
if (!findBoxRef.current.contains(e.target as HTMLElement)) setFindOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const locatePaletteItem = useCallback(
(item: PaletteItem) => {
const node = nodes.find((n) =>
item.source === "managed"
? n.data.managed_ne_id === item.managed_ne_id
: n.data.ume_ne_id === item.ume_ne_id,
);
if (!node) {
showError(t("topology.findNoMatch"));
return;
}
locateNode(node.id);
},
[nodes, locateNode, showError, t],
);
const focusEdge = useCallback(
(edgeId: string) => {
setSelectedEdgeId(edgeId);
@ -1343,7 +1533,7 @@ export function TopologyPage() {
name,
ip: ne.ip_address || "",
vendor: "ZTE",
meta: `${ne.ip_address || "-"} ? ${ne.ne_type || "UME"}`,
meta: `${ne.ip_address || "-"} · ${ne.ne_type || "UME"}`,
connect_status: ne.connection_status || "",
};
});
@ -1356,18 +1546,30 @@ export function TopologyPage() {
name: ne.name || ne.ip_address,
ip: ne.ip_address,
vendor: ne.vendor,
meta: `${ne.ip_address} ? ${ne.vendor}`,
meta: `${ne.ip_address} · ${ne.vendor}`,
connect_status: ne.connect_status,
}));
}, [paletteSource, neQuery.data, umeQuery.data]);
const paletteVisible = useMemo(() => {
if (!hideAddedNes) return palette;
return palette.filter((item) =>
const tokens = keyword
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
let list = palette;
if (tokens.length) {
list = list.filter((item) => {
const bits = [item.name, item.ip, item.vendor, item.managed_ne_id, item.ume_ne_id, item.meta];
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
});
}
if (!hideAddedNes) return list;
return list.filter((item) =>
item.source === "managed"
? !onCanvasManagedIds.has(item.managed_ne_id)
: !onCanvasUmeIds.has(item.ume_ne_id),
);
}, [palette, hideAddedNes, onCanvasManagedIds, onCanvasUmeIds]);
}, [palette, hideAddedNes, onCanvasManagedIds, onCanvasUmeIds, keyword]);
const paletteLoading =
(paletteSource === "managed" && neQuery.isLoading) ||
(paletteSource === "ume" && umeQuery.isLoading);
@ -1384,7 +1586,7 @@ export function TopologyPage() {
onClick={() => setSidebarCollapsed(false)}
>
<span className="topo-sidebar__rail-icon" aria-hidden="true">
›
?
</span>
<span className="topo-sidebar__rail-label">{t("topology.maps")}</span>
</button>
@ -1412,7 +1614,7 @@ export function TopologyPage() {
aria-label={t("topology.collapseSidebar")}
onClick={() => setSidebarCollapsed(true)}
>
‹
?
</button>
</div>
</div>
@ -1446,7 +1648,7 @@ export function TopologyPage() {
disabled={renameMapMut.isPending}
onClick={() => promptRenameMap(m.id, m.name)}
>
✎
?
</button>
<button
type="button"
@ -1457,7 +1659,7 @@ export function TopologyPage() {
if (window.confirm(msg)) deleteMapMut.mutate(m.id);
}}
>
×
?
</button>
</div>
</div>
@ -1531,11 +1733,20 @@ export function TopologyPage() {
<button
type="button"
className="topo-palette__item"
disabled={!mapId || onCanvas}
disabled={!mapId}
draggable={Boolean(mapId) && !onCanvas}
onDragStart={(e) => onPaletteDragStart(e, item)}
onClick={() => addPaletteItem(item)}
title={`${item.name}\n${item.ip}`}
onDragStart={(e) => {
if (onCanvas) {
e.preventDefault();
return;
}
onPaletteDragStart(e, item);
}}
onClick={() => {
if (onCanvas) locatePaletteItem(item);
else addPaletteItem(item);
}}
title={`${item.name}\n${item.ip}${onCanvas ? `\n${t("topology.locateOnCanvas")}` : ""}`}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">
@ -1652,6 +1863,86 @@ export function TopologyPage() {
</button>
))}
</div>
<div
className="topo-toolbar__group topo-toolbar__group--find"
aria-label={t("topology.findNode")}
ref={findBoxRef}
>
<input
className="topo-toolbar__find"
value={canvasQuery}
onChange={(e) => setCanvasQuery(e.target.value)}
onFocus={() => {
if (canvasQuery.trim()) setFindOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!canvasHits.length) return;
setFindOpen(true);
setFindActiveIdx((i) => (i + 1) % canvasHits.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (!canvasHits.length) return;
setFindOpen(true);
setFindActiveIdx((i) => (i - 1 + canvasHits.length) % canvasHits.length);
return;
}
if (e.key === "Escape") {
setFindOpen(false);
return;
}
if (e.key === "Enter") {
e.preventDefault();
findOnCanvas();
}
}}
placeholder={t("topology.findNodePh")}
disabled={!mapId || nodes.length === 0}
aria-label={t("topology.findNode")}
aria-autocomplete="list"
aria-expanded={findOpen}
/>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!mapId || nodes.length === 0 || !canvasQuery.trim()}
onClick={() => findOnCanvas()}
>
{t("topology.findNode")}
</button>
{findOpen && canvasQuery.trim() ? (
<div className="topo-find-suggest" role="listbox">
{canvasHits.length === 0 ? (
<div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div>
) : (
canvasHits.slice(0, 12).map((n, idx) => (
<button
key={n.id}
type="button"
role="option"
aria-selected={idx === findActiveIdx}
className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
onMouseEnter={() => setFindActiveIdx(idx)}
onClick={() => findOnCanvas(n.id)}
>
<span className="topo-find-suggest__name">{n.data.label || n.id}</span>
<span className="topo-find-suggest__meta">
{[n.data.ne_ip, n.data.vendor].filter(Boolean).join(" ? ")}
</span>
</button>
))
)}
{canvasHits.length > 12 ? (
<div className="topo-find-suggest__more">
{t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))}
</div>
) : null}
</div>
) : null}
</div>
<div className="topo-toolbar__group" aria-label={t("topology.layout")}>
<select
className="topo-toolbar__select"
@ -1826,18 +2117,27 @@ export function TopologyPage() {
{discovering ? t("topology.discoverCancel") : t("topology.discoverClose")}
</button>
</div>
{discovering ? (
<p className="panel__hint panel__hint--live">
{discoverProgress.total <= 0
? t("topology.discoverNoTargets")
: t("topology.discoverProgressLive")
{discovering && discoverProgress.total > 0 ? (
<div className="topo-discover__progress" aria-live="polite">
<div className="topo-discover__bar">
<div className="topo-discover__bar-fill" style={{ width: `${discoverPct}%` }} />
</div>
<div className="topo-discover__progress-meta">
<span>
{t("topology.discoverProgressLive")
.replace("{{index}}", String(discoverProgress.index))
.replace("{{total}}", String(discoverProgress.total))
.replace(
"{{name}}",
discoverProgress.neName || discoverProgress.neIp || "?",
)}
</p>
</span>
<span>{discoverPct}%</span>
</div>
</div>
) : null}
{discovering && discoverProgress.total <= 0 ? (
<p className="panel__hint panel__hint--live">{t("topology.discoverNoTargets")}</p>
) : null}
{!discovering && discoverReport && discoverReport.scanned === 0 ? (
<p className="panel__hint">{t("topology.discoverNoTargets")}</p>
@ -1853,28 +2153,40 @@ export function TopologyPage() {
.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>
<div className="topo-discover__filters" role="group" aria-label={t("topology.discoverFilter")}>
{(
[
["issues", t("topology.discoverFilterIssues"), discoverCounts.issues],
["fail", t("topology.discoverFilterFail"), discoverCounts.fail],
["warn", t("topology.discoverFilterWarn"), discoverCounts.warn],
["ok", t("topology.discoverFilterOk"), discoverCounts.ok],
] as const
).map(([key, label, count]) => (
<button
key={key}
type="button"
className="topo-discover__filter"
onClick={() => {
setDiscoverFilter(key);
setDiscoverListOpen(true);
}}
>
{label}
<span className="topo-discover__filter-count">{count}</span>
</button>
))}
</ul>
<button
type="button"
className="topo-discover__filter topo-discover__filter--primary"
onClick={() => {
setDiscoverFilter("all");
setDiscoverListOpen(true);
}}
>
{t("topology.discoverFilterDetail")}
<span className="topo-discover__filter-count">{discoverResults.length}</span>
</button>
</div>
</>
) : null}
</div>
@ -1889,7 +2201,11 @@ export function TopologyPage() {
{mapId ? (
<TopoDisplayContext.Provider value={displayOpts}>
<ReactFlow
nodes={nodes}
nodes={nodes.map((n) =>
searchHitIds.includes(n.id)
? { ...n, className: "is-search-hit" }
: { ...n, className: undefined },
)}
edges={displayEdges}
nodeTypes={nodeTypes}
connectionMode={ConnectionMode.Loose}
@ -2183,6 +2499,16 @@ export function TopologyPage() {
</div>
</li>
<li className="topo-ctx__sep" aria-hidden />
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => openPortTrafficForEdge(selectedEdge)}
>
{t("topology.openPortTraffic")}
</button>
</li>
<li role="none">
<button
type="button"
@ -2209,6 +2535,319 @@ export function TopologyPage() {
)}
</ul>
) : null}
{discoverListOpen ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setDiscoverListOpen(false)}
>
<div
className="modal modal--wide topo-discover-modal topo-discover-list-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-discover-list-title"
onClick={(e) => e.stopPropagation()}
>
<div className="topo-discover-modal__head">
<div>
<h3 id="topo-discover-list-title">{t("topology.discoverListTitle")}</h3>
<p className="topo-discover-modal__sub">
{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>
</div>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setDiscoverListOpen(false)}
>
{t("topology.discoverClose")}
</button>
</div>
<div className="topo-discover__filters" role="tablist" aria-label={t("topology.discoverFilter")}>
{(
[
["issues", t("topology.discoverFilterIssues"), discoverCounts.issues],
["fail", t("topology.discoverFilterFail"), discoverCounts.fail],
["warn", t("topology.discoverFilterWarn"), discoverCounts.warn],
["ok", t("topology.discoverFilterOk"), discoverCounts.ok],
["all", t("topology.discoverFilterAll"), discoverResults.length],
] as const
).map(([key, label, count]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={discoverFilter === key}
className={`topo-discover__filter${discoverFilter === key ? " is-active" : ""}`}
onClick={() => setDiscoverFilter(key)}
>
{label}
<span className="topo-discover__filter-count">{count}</span>
</button>
))}
</div>
{discoverVisible.length === 0 ? (
<p className="panel__hint">{t("topology.discoverListEmpty")}</p>
) : (
<ul className="topo-discover__list topo-discover__list--modal">
{discoverVisible.map((r) => {
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
const kind = discoverResultKind(r);
const title = r.ne_name || r.ne_ip || r.ne_id;
const linkCount = r.links?.length || 0;
return (
<li key={r.ne_id} className={`is-${kind}`}>
<button
type="button"
className="topo-discover__row"
onClick={() => setDiscoverDetail(r)}
>
<span className="topo-discover__row-main">
<span className="topo-discover__row-name">{title}</span>
{r.ne_ip ? <span className="topo-discover__row-ip">{r.ne_ip}</span> : null}
<span className="topo-discover__row-meta">
{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")}
{linkCount > 0
? ` ? ${t("topology.discoverLinkCount").replace("{{count}}", String(linkCount))}`
: ""}
{r.ok && unmatchedCount > 0
? ` ? ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
: ""}
</span>
</span>
<span className="topo-discover__row-actions">
<span className="topo-discover__badge">
{kind === "fail"
? "FAIL"
: kind === "warn"
? r.parser_stub
? "STUB"
: "WARN"
: "OK"}
</span>
<span className="topo-discover__detail-btn">{t("topology.discoverViewDetail")}</span>
</span>
</button>
</li>
);
})}
</ul>
)}
{discoverFiltered.length > DISCOVER_LIST_CAP ? (
<p className="topo-discover__cap">
{t("topology.discoverShowLimited")
.replace("{{shown}}", String(DISCOVER_LIST_CAP))
.replace("{{total}}", String(discoverFiltered.length))}
</p>
) : null}
</div>
</div>
) : null}
{discoverDetail ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setDiscoverDetail(null)}
>
<div
className="modal modal--wide topo-discover-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-discover-detail-title"
onClick={(e) => e.stopPropagation()}
>
<div className="topo-discover-modal__head">
<div>
<h3 id="topo-discover-detail-title">
{discoverDetail.ne_name || discoverDetail.ne_ip || discoverDetail.ne_id}
</h3>
<p className="topo-discover-modal__sub">
{[discoverDetail.ne_ip, discoverDetail.command].filter(Boolean).join(" ? ")}
</p>
</div>
<span
className={`topo-discover__badge topo-discover__badge--lg is-${discoverResultKind(discoverDetail)}`}
>
{discoverResultKind(discoverDetail) === "fail"
? "FAIL"
: discoverResultKind(discoverDetail) === "warn"
? discoverDetail.parser_stub
? "STUB"
: "WARN"
: "OK"}
</span>
</div>
{!discoverDetail.ok ? (
<p className="topo-discover__error">
{discoverDetail.error || t("topology.discoverNeFail")}
</p>
) : null}
{discoverDetail.parser_stub ? (
<p className="topo-discover__item-warn">
{t("topology.discoverParserStub").replace(
"{{parser}}",
discoverDetail.parser_key || "unknown",
)}
</p>
) : null}
<div className="topo-discover-modal__stats">
<span>
{t("topology.discoverNeOk")
.replace("{{neighbors}}", String(discoverDetail.neighbors || 0))
.replace("{{added}}", String(discoverDetail.edges_added || 0))
.replace("{{updated}}", String(discoverDetail.edges_updated || 0))}
</span>
</div>
<h4 className="topo-discover-modal__section">
{t("topology.discoverLinksTitle").replace(
"{{count}}",
String(discoverDetail.links?.length || 0),
)}
</h4>
{(discoverDetail.links || []).length === 0 ? (
<p className="panel__hint">{t("topology.discoverLinksEmpty")}</p>
) : (
<div className="topo-discover-modal__table-wrap">
<table className="topo-discover-modal__table">
<thead>
<tr>
<th>{t("topology.discoverColPeer")}</th>
<th>{t("topology.discoverColLocalPort")}</th>
<th>{t("topology.discoverColRemotePort")}</th>
<th>{t("topology.discoverColProtocol")}</th>
<th>{t("topology.discoverColAction")}</th>
<th />
</tr>
</thead>
<tbody>
{(discoverDetail.links || []).map((link, idx) => (
<tr key={`${discoverDetail.ne_id}-l-${idx}`}>
<td>
<div className="topo-discover-modal__peer">
<strong>{link.peer_name || link.peer_ip || link.peer_ne_id || "?"}</strong>
{link.peer_ip ? <span>{link.peer_ip}</span> : null}
</div>
</td>
<td>{link.local_port || "?"}</td>
<td>{link.remote_port || "?"}</td>
<td>{(link.protocol || "?").toUpperCase()}</td>
<td>
<span className={`topo-discover-action is-${link.action || ""}`}>
{link.action === "added"
? t("topology.discoverLinkAdded")
: link.action === "updated"
? t("topology.discoverLinkUpdated")
: link.action === "kept_manual"
? t("topology.discoverLinkKeptManual")
: link.action || "?"}
</span>
</td>
<td>
{link.peer_node_id ? (
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
const nid = String(link.peer_node_id || "");
setDiscoverDetail(null);
locateNode(nid);
}}
>
{t("topology.discoverLocatePeer")}
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<h4 className="topo-discover-modal__section">
{t("topology.discoverUnmatchedTitle").replace(
"{{count}}",
String(discoverDetail.unmatched_count ?? discoverDetail.unmatched?.length ?? 0),
)}
</h4>
{(discoverDetail.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>
{(discoverDetail.unmatched || []).map((u, idx) => (
<tr key={`${discoverDetail.ne_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>
)}
{discoverDetail.raw_preview ? (
<>
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
<pre className="topo-discover-modal__raw">{discoverDetail.raw_preview}</pre>
</>
) : null}
<div className="modal__actions">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
const id = String(discoverDetail.ne_id || "");
const node = nodes.find(
(n) => n.data.managed_ne_id === id || n.data.ume_ne_id === id,
);
setDiscoverDetail(null);
if (node) locateNode(node.id);
else showError(t("topology.findNoMatch"));
}}
>
{t("topology.discoverLocateNe")}
</button>
<button type="button" className="btn btn--sm" onClick={() => setDiscoverDetail(null)}>
{t("topology.discoverClose")}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import {
collectPortTrafficNow,
createPortTrafficTask,
@ -48,6 +49,8 @@ export function PortTrafficPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkAppliedRef = useRef(false);
const [view, setView] = useState<"list" | "wizard" | "wall">("list");
const [taskPage, setTaskPage] = useState(1);
@ -73,6 +76,9 @@ export function PortTrafficPage() {
Record<string, { loading?: boolean; error?: string; ports: PortTrafficDiscoverPort[]; meta?: { ne_name: string; ne_ip: string; vendor: string } }>
>({});
const [pickedPorts, setPickedPorts] = useState<Record<string, PortPick>>({});
const [deepLinkHint, setDeepLinkHint] = useState("");
const [deepLinkSource, setDeepLinkSource] = useState<"managed" | "ume" | "">("");
const [deepLinkNeId, setDeepLinkNeId] = useState("");
const dashQuery = useQuery({
queryKey: queryKeys.portTrafficDashboard,
@ -150,6 +156,44 @@ export function PortTrafficPage() {
? EMPTY_COMPARE_POINTS
: (compareQuery.data?.baseline ?? EMPTY_COMPARE_POINTS);
useEffect(() => {
if (deepLinkAppliedRef.current) return;
const neId = (searchParams.get("ne_id") || "").trim();
if (!neId) return;
deepLinkAppliedRef.current = true;
const source = searchParams.get("source") === "ume" ? "ume" : "managed";
const ifname = (searchParams.get("ifname") || "").trim();
setDeepLinkNeId(neId);
setDeepLinkSource(source);
setView("wizard");
setStep(2);
setNeKeyword(neId);
setNePage(1);
setDeepLinkHint(
t("portTraffic.deepLinkHint")
.replace("{{ne}}", neId)
.replace(
"{{ifname}}",
ifname ? t("portTraffic.deepLinkIfname").replace("{{ifname}}", ifname) : "",
),
);
setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams, t]);
useEffect(() => {
if (!deepLinkNeId || view !== "wizard" || step !== 2) return;
const items = nesQuery.data?.items || [];
const hit = items.find((row) => {
const src = row.source === "ume" ? "ume" : "managed";
if (deepLinkSource && src !== deepLinkSource) return false;
return row.id === deepLinkNeId;
});
if (!hit) return;
const k = neKey(hit.source, hit.id);
setSelectedNes((prev) => (prev[k] ? prev : { ...prev, [k]: hit }));
setDeepLinkNeId("");
}, [deepLinkNeId, deepLinkSource, nesQuery.data, view, step]);
const invalidateAll = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTasksAll });
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDashboard });
@ -221,6 +265,9 @@ export function PortTrafficPage() {
setPickedPorts({});
setNeKeyword("");
setNePage(1);
setDeepLinkHint("");
setDeepLinkSource("");
setDeepLinkNeId("");
};
const openWizard = () => {
@ -542,6 +589,7 @@ export function PortTrafficPage() {
{step === 2 ? (
<>
{deepLinkHint ? <p className="panel__hint panel__hint--live">{deepLinkHint}</p> : null}
<div className="filter-inline">
<input
value={neKeyword}

View file

@ -389,6 +389,24 @@ export type TopologyGraph = {
edges: TopologyEdgeItem[];
};
export type TopologyDiscoverUnmatched = {
remote_name: string;
remote_ip: string;
local_port: string;
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 = {
ne_id: string;
ne_name: string;
@ -398,6 +416,11 @@ export type TopologyDiscoverNeResult = {
neighbors: number;
edges_added: number;
edges_updated: number;
unmatched_count?: number;
unmatched?: TopologyDiscoverUnmatched[];
links?: TopologyDiscoverLink[];
parser_key?: string;
parser_stub?: boolean;
error: string;
raw_preview: string;
};