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
- {discoverProgress.total <= 0
- ? t("topology.discoverNoTargets")
- : t("topology.discoverProgressLive")
+ {discovering && discoverProgress.total > 0 ? (
+ {t("topology.discoverNoTargets")} {t("topology.discoverNoTargets")}