diff --git a/netx_api/main.py b/netx_api/main.py index 749b64f..7170d47 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -1945,12 +1945,13 @@ def ume_list_ne( stmt = db.query(UmeInventoryNE) kw = str(keyword or "").strip() if kw: + like = f"%{kw}%" stmt = stmt.filter( - UmeInventoryNE.ne_id.contains(kw) - | UmeInventoryNE.ne_name.contains(kw) - | UmeInventoryNE.user_label.contains(kw) - | UmeInventoryNE.ip_address.contains(kw) - | UmeInventoryNE.host_name.contains(kw) + UmeInventoryNE.ne_id.ilike(like) + | UmeInventoryNE.ne_name.ilike(like) + | UmeInventoryNE.user_label.ilike(like) + | UmeInventoryNE.ip_address.ilike(like) + | UmeInventoryNE.host_name.ilike(like) ) total = int(stmt.count()) rows = stmt.order_by(UmeInventoryNE.ne_id.asc()).offset((page - 1) * page_size).limit(page_size).all() diff --git a/netx_api/topology_lldp.py b/netx_api/topology_lldp.py index d1365c6..2affdfc 100644 --- a/netx_api/topology_lldp.py +++ b/netx_api/topology_lldp.py @@ -253,6 +253,15 @@ _VENDOR_PARSERS: dict[str, ParserFn] = { "generic": parse_generic_lldp, } +# Parsers that intentionally return [] until lab samples are added. +STUB_PARSER_KEYS = frozenset({"h3c", "juniper", "nokia", "ericsson"}) + + +def parser_meta(*, vendor: str = "", device_type: str = "") -> tuple[str, bool]: + """Return (parser_key, is_stub).""" + key = resolve_vendor_key(vendor, device_type) + return key, key in STUB_PARSER_KEYS + def parse_neighbor_output( text: str, diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 512d477..a8a4eba 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -98,6 +98,24 @@ class TopologyDiscoverRequest(BaseModel): ne_ids: list[str] | None = None +class TopologyDiscoverUnmatched(BaseModel): + remote_name: str = "" + remote_ip: str = "" + local_port: str = "" + remote_port: str = "" + + +class TopologyDiscoverLink(BaseModel): + peer_node_id: str = "" + peer_ne_id: str = "" + peer_name: str = "" + peer_ip: str = "" + local_port: str = "" + remote_port: str = "" + protocol: str = "" + action: str = "" # added | updated | kept_manual + + class TopologyDiscoverNeResult(BaseModel): ne_id: str ne_name: str = "" @@ -107,6 +125,11 @@ class TopologyDiscoverNeResult(BaseModel): neighbors: int = 0 edges_added: int = 0 edges_updated: int = 0 + unmatched_count: int = 0 + unmatched: list[TopologyDiscoverUnmatched] = Field(default_factory=list) + links: list[TopologyDiscoverLink] = Field(default_factory=list) + parser_key: str = "" + parser_stub: bool = False error: str = "" raw_preview: str = "" diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py index 81a5494..1b5be91 100644 --- a/netx_api/topology_service.py +++ b/netx_api/topology_service.py @@ -13,11 +13,19 @@ from sqlalchemy.orm import Session from .cli_resolve import get_default_profile, infer_device_type_vendor from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE from .ne_exec import execute_managed_ne_commands -from .topology_lldp import NeighborHit, normalize_ifname, parse_neighbor_output, pick_neighbor_command +from .topology_lldp import ( + NeighborHit, + normalize_ifname, + parse_neighbor_output, + parser_meta, + pick_neighbor_command, +) from .topology_schemas import ( + TopologyDiscoverLink, TopologyDiscoverNeResult, TopologyDiscoverOut, TopologyDiscoverRequest, + TopologyDiscoverUnmatched, TopologyEdgeIn, TopologyEdgeOut, TopologyGraphOut, @@ -334,6 +342,24 @@ def re_sub_host(s: str) -> str: return t.rstrip(".,;:") +def _peer_display( + peer: TopologyNode, + nes: dict[str, ManagedNE], + umes: dict[str, UmeInventoryNE], +) -> tuple[str, str, str]: + """Return (peer_ne_id, peer_name, peer_ip).""" + mid = str(peer.managed_ne_id or "").strip() + uid = str(peer.ume_ne_id or "").strip() + if mid and mid in nes: + ne = nes[mid] + return mid, (ne.name or peer.label or mid)[:256], str(ne.ip_address or "") + if uid and uid in umes: + ume = umes[uid] + name = (ume.host_name or ume.ne_name or ume.user_label or peer.label or uid).strip() + return uid, name[:256], str(ume.ip_address or "") + return "", (peer.label or peer.id)[:256], "" + + def _match_neighbor_to_node( hit: NeighborHit, *, @@ -374,6 +400,17 @@ def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[s return (b, a, rp, lp) +_RAW_PREVIEW_MAX = 12_000 + + +def _raw_preview(raw: str, *, limit: int = _RAW_PREVIEW_MAX) -> str: + """UI preview only; discovery parsing always uses the full command output.""" + text = str(raw or "") + if len(text) <= limit: + return text + return f"{text[:limit]}\n...[truncated preview {limit}/{len(text)} chars]" + + def _discover_target_for_node( n: TopologyNode, *, @@ -548,6 +585,7 @@ def iter_discover_neighbors( scanned_ok_node_ids.add(n.id) raw = str(exec_out.get("output") or "") + pkey, is_stub = parser_meta(vendor=target["vendor"], device_type=target["device_type"]) hits = parse_neighbor_output( raw, protocol=proto_tag, @@ -556,19 +594,42 @@ def iter_discover_neighbors( ) ne_added = 0 ne_updated = 0 + unmatched: list[TopologyDiscoverUnmatched] = [] + links: list[TopologyDiscoverLink] = [] for hit in hits: peer = _match_neighbor_to_node( hit, nodes=nodes, nes=nes, umes=umes, self_node_id=n.id ) if peer is None: + unmatched.append( + TopologyDiscoverUnmatched( + remote_name=(hit.remote_name or "").strip()[:256], + remote_ip=(hit.remote_ip or "").strip()[:128], + local_port=(hit.local_port or "").strip()[:128], + remote_port=(hit.remote_port or "").strip()[:128], + ) + ) continue local_port = (hit.local_port or "").strip()[:128] remote_port = (hit.remote_port or "").strip()[:128] key = _edge_pair_key(n.id, peer.id, local_port, remote_port) edge_proto = hit.protocol if hit.protocol in {"lldp", "cdp"} else proto_tag + peer_ne_id, peer_name, peer_ip = _peer_display(peer, nes, umes) cur = existing.get(key) if cur is not None: if (cur.source or "manual") == "manual": + links.append( + TopologyDiscoverLink( + peer_node_id=peer.id, + peer_ne_id=peer_ne_id, + peer_name=peer_name, + peer_ip=peer_ip, + local_port=local_port, + remote_port=remote_port, + protocol=edge_proto, + action="kept_manual", + ) + ) continue cur.source = edge_proto cur.source_port = local_port if cur.source_node_id == n.id else remote_port @@ -578,6 +639,18 @@ def iter_discover_neighbors( touched_edge_ids.add(cur.id) ne_updated += 1 updated += 1 + links.append( + TopologyDiscoverLink( + peer_node_id=peer.id, + peer_ne_id=peer_ne_id, + peer_name=peer_name, + peer_ip=peer_ip, + local_port=local_port, + remote_port=remote_port, + protocol=edge_proto, + action="updated", + ) + ) continue new_edge = TopologyEdge( id=uuid4().hex, @@ -596,7 +669,20 @@ def iter_discover_neighbors( touched_edge_ids.add(new_edge.id) ne_added += 1 added += 1 + links.append( + TopologyDiscoverLink( + peer_node_id=peer.id, + peer_ne_id=peer_ne_id, + peer_name=peer_name, + peer_ip=peer_ip, + local_port=local_port, + remote_port=remote_port, + protocol=edge_proto, + action="added", + ) + ) + stub_flag = bool(is_stub and raw.strip() and not hits) result = TopologyDiscoverNeResult( ne_id=target["ne_id"], ne_name=target["ne_name"], @@ -606,7 +692,13 @@ def iter_discover_neighbors( neighbors=len(hits), edges_added=ne_added, edges_updated=ne_updated, - raw_preview=raw[:800], + unmatched_count=len(unmatched), + unmatched=unmatched[:40], + links=links[:80], + parser_key=pkey, + parser_stub=stub_flag, + raw_preview=_raw_preview(raw), + error="parser_stub" if stub_flag else "", ) results.append(result) yield { diff --git a/tests/test_topology.py b/tests/test_topology.py index 1b8fd03..17a62f8 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -474,6 +474,7 @@ class TopologyServiceTests(unittest.TestCase): self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id]) ) self.assertGreaterEqual(out2.edges_added, 1) + self.assertTrue(out2.results and any(l.action == "added" for l in (out2.results[0].links or []))) graph2 = svc.get_graph(self.db, mid) self.assertTrue(any(e.source == "cdp" for e in graph2.edges)) @@ -596,6 +597,49 @@ class TopologyServiceTests(unittest.TestCase): self.db.delete(ne_b) self.db.commit() + def test_discover_reports_unmatched_neighbors(self) -> None: + suffix = uuid4().hex[:8] + ne_a_id = f"nea-{suffix}" + ip_a = f"203.0.113.{(int(suffix[:2], 16) % 100) + 1}" + ne_a = ManagedNE( + id=ne_a_id, + name="R2", + vendor="Cisco", + device_type="cisco_ios", + ip_address=ip_a, + connect_status="pass", + ) + self.db.add(ne_a) + self.db.commit() + created = svc.create_map(self.db, TopologyMapCreate(name=f"Unmatch-{suffix}")) + mid = created.id + svc.put_graph( + self.db, + mid, + TopologyGraphPut( + nodes=[TopologyNodeIn(id="n1", managed_ne_id=ne_a_id, label="R2", x=0, y=0)], + edges=[], + ), + ) + fake_exec = { + "ok": True, + "output": CISCO_CDP_DETAIL, + "commands": ["show cdp neighbors detail"], + } + with patch.object(svc, "execute_managed_ne_commands", return_value=fake_exec): + out = svc.discover_neighbors( + self.db, mid, TopologyDiscoverRequest(protocol="cdp", ne_ids=[ne_a_id]) + ) + self.assertEqual(len(out.results), 1) + r = out.results[0] + self.assertTrue(r.ok) + self.assertGreaterEqual(r.unmatched_count, 1) + self.assertTrue(any((u.remote_name or "").upper().startswith("R1") for u in r.unmatched)) + self.assertEqual(r.links, []) + svc.delete_map(self.db, mid) + self.db.delete(ne_a) + self.db.commit() + class IfnameNormalizeTests(unittest.TestCase): def test_cisco_huawei_aliases(self) -> None: @@ -611,6 +655,14 @@ class IfnameNormalizeTests(unittest.TestCase): b = svc._edge_pair_key("n1", "n2", "Gi0/0", "GigabitEthernet0/1") self.assertEqual(a, b) + def test_parser_meta_marks_stubs(self) -> None: + key, stub = lldp.parser_meta(vendor="H3C", device_type="hp_comware") + self.assertEqual(key, "h3c") + self.assertTrue(stub) + key2, stub2 = lldp.parser_meta(vendor="Cisco", device_type="cisco_ios") + self.assertEqual(key2, "cisco") + self.assertFalse(stub2) + if __name__ == "__main__": unittest.main() diff --git a/web/WEB.md b/web/WEB.md index b09017b..354a261 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -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 diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index fc92f5d..f48dfb8 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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)", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 16292c4..0cfae08 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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}} 条未发现链路", diff --git a/web/src/index.css b/web/src/index.css index bae3df5..407facd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index c861e3a..574536f 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -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, 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([]); + const [findOpen, setFindOpen] = useState(false); + const [findActiveIdx, setFindActiveIdx] = useState(0); + const [discoverFilter, setDiscoverFilter] = useState<"issues" | "fail" | "warn" | "ok" | "all">( + "issues", + ); + const [discoverDetail, setDiscoverDetail] = useState(null); + const [discoverListOpen, setDiscoverListOpen] = useState(false); const discoverAbortRef = useRef(null); + const searchHitTimerRef = useRef(null); + const findBoxRef = useRef(null); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const rfRef = useRef, 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 | 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)} > {t("topology.maps")} @@ -1412,7 +1614,7 @@ export function TopologyPage() { aria-label={t("topology.collapseSidebar")} onClick={() => setSidebarCollapsed(true)} > - ‹ + ? @@ -1446,7 +1648,7 @@ export function TopologyPage() { disabled={renameMapMut.isPending} onClick={() => promptRenameMap(m.id, m.name)} > - ✎ + ? @@ -1531,11 +1733,20 @@ export function TopologyPage() { ))} +
+ 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} + /> + + {findOpen && canvasQuery.trim() ? ( +
+ {canvasHits.length === 0 ? ( +
{t("topology.findNoMatch")}
+ ) : ( + canvasHits.slice(0, 12).map((n, idx) => ( + + )) + )} + {canvasHits.length > 12 ? ( +
+ {t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))} +
+ ) : null} +
+ ) : null} +