diff --git a/netx_api/topology_classify_slices.py b/netx_api/topology_classify_slices.py index 0b0a63b..593006e 100644 --- a/netx_api/topology_classify_slices.py +++ b/netx_api/topology_classify_slices.py @@ -297,17 +297,47 @@ def search_fabric_nodes_with_views( page: int = 1, page_size: int = 50, ) -> dict[str, Any]: + """Search fabric NEs and resolve which canvases they appear on. + + Membership views (TopoViewNode) come first. UME synthetic canvases usually have + no membership rows — fall back to region_folder_id → primary canvas, and to the + world map when world_x/y are set. + """ + from sqlalchemy import or_ + + from .models import UmeInventoryNE + from .topology_region_canvas import primary_canvas_view from .topology_service import _node_out + from .ume_topology_world import get_world_flat_view, is_world_flat_visible q = db.query(TopoFabricNode) kw = str(keyword or "").strip() if kw: like = f"%{kw}%" - q = q.filter( - (TopoFabricNode.name.ilike(like)) - | (TopoFabricNode.ip.ilike(like)) - | (TopoFabricNode.vendor.ilike(like)) - ) + inv_ids = [ + str(r[0]) + for r in db.query(UmeInventoryNE.ne_id) + .filter( + or_( + UmeInventoryNE.user_label.ilike(like), + UmeInventoryNE.ne_name.ilike(like), + UmeInventoryNE.host_name.ilike(like), + UmeInventoryNE.ip_address.ilike(like), + ) + ) + .limit(500) + .all() + if str(r[0] or "").strip() + ] + clauses = [ + TopoFabricNode.name.ilike(like), + TopoFabricNode.ip.ilike(like), + TopoFabricNode.vendor.ilike(like), + TopoFabricNode.ume_ne_id.ilike(like), + ] + if inv_ids: + clauses.append(TopoFabricNode.ume_ne_id.in_(inv_ids)) + q = q.filter(or_(*clauses)) total = q.count() rows = ( q.order_by(TopoFabricNode.name.asc()) @@ -317,6 +347,23 @@ def search_fabric_nodes_with_views( ) node_ids = [n.id for n in rows] placements: dict[str, list[dict[str, Any]]] = {nid: [] for nid in node_ids} + seen: dict[str, set[str]] = {nid: set() for nid in node_ids} + + def _add_view(nid: str, view: TopoView, folder: TopoFolder | None) -> None: + vid = str(view.id or "").strip() + if not vid or vid in seen.get(nid, set()): + return + seen.setdefault(nid, set()).add(vid) + placements.setdefault(nid, []).append( + { + "view_id": view.id, + "view_name": view.name, + "folder_id": view.folder_id or "", + "folder_name": (folder.name if folder else "") or "", + "kind": view.kind or "custom", + } + ) + if node_ids: vnodes = ( db.query(TopoViewNode, TopoView, TopoFolder) @@ -326,15 +373,31 @@ def search_fabric_nodes_with_views( .all() ) for vn, view, folder in vnodes: - placements.setdefault(vn.fabric_node_id, []).append( - { - "view_id": view.id, - "view_name": view.name, - "folder_id": view.folder_id or "", - "folder_name": (folder.name if folder else "") or "", - "kind": view.kind or "custom", - } - ) + _add_view(vn.fabric_node_id, view, folder) + + folder_ids = { + str(n.region_folder_id or "").strip() + for n in rows + if str(n.region_folder_id or "").strip() + } + folders_by_id: dict[str, TopoFolder] = {} + if folder_ids: + for f in db.query(TopoFolder).filter(TopoFolder.id.in_(list(folder_ids))).all(): + folders_by_id[str(f.id)] = f + + flat = get_world_flat_view(db) + flat_ok = flat is not None and is_world_flat_visible(flat) + flat_folder = db.get(TopoFolder, flat.folder_id) if flat_ok and flat and flat.folder_id else None + + for n in rows: + rid = str(n.region_folder_id or "").strip() + if rid: + view = primary_canvas_view(db, rid) + if view is not None: + _add_view(n.id, view, folders_by_id.get(rid)) + if flat_ok and flat is not None and n.world_x is not None and n.world_y is not None: + _add_view(n.id, flat, flat_folder) + items = [] for n in rows: d = _node_out(n).model_dump() diff --git a/web/src/index.css b/web/src/index.css index eae77a7..3b9f012 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -4494,10 +4494,11 @@ pre { max-height: min(360px, 50vh); overflow: auto; padding: 4px; - border: 1px solid #b8c4d2; + border: 1px solid rgba(148, 163, 184, 0.28); border-radius: 8px; - background: #fff; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + background: #0b1220; + color: #e2e8f0; + box-shadow: 0 8px 24px rgba(2, 8, 23, 0.55); } .topo-tree-search__item { @@ -4510,7 +4511,7 @@ pre { .topo-tree-search__item + .topo-tree-search__item { margin-top: 2px; - border-top: 1px solid #eef2f6; + border-top: 1px solid rgba(148, 163, 184, 0.16); padding-top: 4px; } @@ -4530,14 +4531,14 @@ pre { } .topo-tree-search__ne:hover { - background: #f1f5f9; + background: rgba(148, 163, 184, 0.12); } .topo-tree-search__name { grid-column: 1; font-size: 12px; font-weight: 600; - color: var(--nm-ink, #0f172a); + color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -4566,11 +4567,11 @@ pre { .topo-tree-search__view { max-width: 100%; padding: 2px 8px; - border: 1px solid #d0dae6; + border: 1px solid rgba(148, 163, 184, 0.28); border-radius: 999px; - background: #f8fafc; + background: rgba(30, 41, 59, 0.9); font-size: 11px; - color: var(--nm-brand, #1d4ed8); + color: #93c5fd; cursor: pointer; overflow: hidden; text-overflow: ellipsis; @@ -4578,8 +4579,8 @@ pre { } .topo-tree-search__view:hover { - background: #e8eef8; - border-color: #9eb6d8; + background: rgba(51, 65, 85, 0.95); + border-color: rgba(96, 165, 250, 0.45); } .topo-tree-search__hint { @@ -5561,12 +5562,29 @@ pre { flex-direction: column; gap: 2px; padding: 2px 0; + /* Allow deep indent + actions to extend; parent scroll shows a horizontal bar. */ + width: max-content; + min-width: 100%; + max-width: none; + box-sizing: border-box; +} + +.topo-region-list-scroll { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow-x: auto; + overflow-y: auto; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgba(2, 8, 23, 0.45); + padding: 2px 0; } .topo-region-list__block { margin: 0; width: auto; - max-width: 100%; + max-width: none; min-width: 0; padding: 2px 0 4px; box-sizing: border-box; @@ -5585,6 +5603,8 @@ pre { overflow: visible; gap: 2px; padding-right: 2px; + width: max-content; + min-width: 100%; transition: background 0.12s ease; } @@ -5691,6 +5711,7 @@ pre { .topo-region-list .topo-map-list__actions { opacity: 0; pointer-events: none; + flex: 0 0 auto; border-left: 0; gap: 4px; padding-right: 4px; @@ -5730,10 +5751,10 @@ pre { } .topo-region-list .topo-map-list__title { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; - text-overflow: ellipsis; + min-width: max-content; + flex: 0 1 auto; + overflow: visible; + text-overflow: clip; white-space: nowrap; font-size: 13px; font-weight: 650; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index c1ae5e4..aaa4851 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -276,11 +276,23 @@ function mergeFlatWorldGraph( next: TopologyViewGraph, opts?: { centerX?: number; centerY?: number }, ): TopologyViewGraph { - if (!prev?.nodes?.length) return next; + const scatter = next.scatter?.length ? next.scatter : prev?.scatter; + if (!prev?.nodes?.length) { + return scatter?.length && !next.scatter?.length ? { ...next, scatter } : next; + } + // Overview / zoom-out payloads have empty RF nodes — keep (or refresh) scatter, + // drop heavy tiles when the server intentionally cleared them. if (!next?.nodes?.length) { return { ...prev, + view: next.view || prev.view, + nodes: [], + edges: [], + scatter: scatter || [], world_transform: next.world_transform ?? prev.world_transform, + truncated: Boolean(next.truncated || prev.truncated), + truncate_reason: next.truncate_reason || prev.truncate_reason || "", + outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers, }; } @@ -320,7 +332,7 @@ function mergeFlatWorldGraph( nodes, edges, world_transform: next.world_transform ?? prev.world_transform, - scatter: next.scatter?.length ? next.scatter : prev.scatter, + scatter: scatter || [], truncated: Boolean(prev.truncated || next.truncated), truncate_reason: next.truncate_reason || prev.truncate_reason || "", outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers, @@ -1428,12 +1440,14 @@ export function TopologyPage() { const cx = bounds ? bounds.x + bounds.width / 2 : 0; const cy = bounds ? bounds.y + bounds.height / 2 : 0; commit(g, cx, cy); - } else if (prev.nodes.length > 0 && zoom < 0.04) { + } else if (prev.nodes.length > 0) { + // Always shed RF tiles when leaving close-up so scatter is the sole layer. commit( { ...prev, nodes: [], edges: [], + scatter: prev.scatter, world_transform: prev.world_transform ? { ...prev.world_transform, lod: "overview" } : prev.world_transform, @@ -4156,7 +4170,7 @@ export function TopologyPage() { )} - {views.length > 1 ? ( + {views.length > 0 ? (
{t("topology.emptyMaps")}
) : ( -