Polish topology tree UI and harden LLDP peer matching.

Make left-tree selection, hex browser, and sidebar chrome clearer while matching peers by hostname/ports instead of mgmt IP and reducing fabric-node insert deadlocks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 15:42:15 +08:00
parent 3908b78dbb
commit cb8e0d23c5
6 changed files with 1520 additions and 364 deletions

View file

@ -1225,12 +1225,20 @@ const en = {
tree: "Topology tree",
treeLoading: "Loading tree…",
treeHint: "Regions hold a physical map plus optional custom maps; open a map to edit the canvas.",
treeSearch: "Search NEs across maps",
treeSearchPh: "Name / IP",
treeSearchEmpty: "No matching NEs",
treeSearchNoViews: "Not on any map",
treeSearchNotOnMap: "This NE is not placed on any topology map yet",
treeSearchViewCount: "{{count}} map(s)",
treeSearchTruncated: "Showing first 30 of {{total}}",
rootName: "Regions",
selectRegion: "Select a region",
openMap: "Open map",
openRegion: "Open region",
kindPhysical: "Physical",
kindCustom: "Custom",
regionNodeHint: "{{count}} map(s)",
regionNodeHint: "{{count}}P",
layerNodeHint: "{{count}} NE(s)",
layerNodeEmpty: "Click to create & open",
rootCanvasHint: "Pick a region from the left tree.",
@ -1238,6 +1246,7 @@ const en = {
regionBrowseHint: "Region folder: physical + custom maps. Click to open.",
browserTitle: "Topology folder",
browserRegionSub: "{{count}} map(s)",
browserRegionsSub: "{{count}} region(s)",
browserEmptyMaps: "No maps in this region yet.",
addNe: "Add NE",
backUp: "Back to region",

View file

@ -1219,12 +1219,20 @@ const zh = {
tree: "拓扑树",
treeLoading: "加载拓扑树…",
treeHint: "区域下为物理拓扑与自定义图;点图打开设备画布。",
treeSearch: "全拓扑搜索网元",
treeSearchPh: "名称 / IP",
treeSearchEmpty: "没有匹配的网元",
treeSearchNoViews: "未上图",
treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上",
treeSearchViewCount: "{{count}} 张图",
treeSearchTruncated: "仅显示前 30 条(共 {{total}})",
rootName: "区域",
selectRegion: "请选择区域",
openMap: "打开本图",
openRegion: "打开区域",
kindPhysical: "物理拓扑",
kindCustom: "自定义图",
regionNodeHint: "{{count}} 张图",
regionNodeHint: "{{count}}P",
layerNodeHint: "{{count}} 个网元",
layerNodeEmpty: "点击创建并打开",
rootCanvasHint: "从左侧选择区域进入目录。",
@ -1232,6 +1240,7 @@ const zh = {
regionBrowseHint: "区域目录:物理拓扑 + 自定义图。点击打开。",
browserTitle: "拓扑目录",
browserRegionSub: "共 {{count}} 张拓扑图",
browserRegionsSub: "共 {{count}} 个区域",
browserEmptyMaps: "此区域还没有拓扑图。",
addNe: "添加网元",
backUp: "返回区域",

View file

@ -3514,23 +3514,155 @@ pre {
flex: 1;
}
.topo-sidebar__head {
display: flex;
.app-main .topo-sidebar__icon-btn,
.topo-sidebar__icon-btn {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
justify-content: center;
width: 24px;
height: 24px;
min-width: 24px;
padding: 0 !important;
margin: 0;
border: 0 !important;
border-radius: 4px !important;
background: transparent !important;
box-shadow: none !important;
color: #64748b;
cursor: pointer;
flex: 0 0 auto;
}
.topo-sidebar__head > strong {
font-size: 13px;
font-weight: 650;
color: var(--nm-brand);
.app-main .topo-sidebar__icon-btn:hover:not(:disabled),
.topo-sidebar__icon-btn:hover:not(:disabled) {
color: var(--nm-brand, #0f2744);
background: rgba(15, 39, 68, 0.06) !important;
}
.topo-sidebar__head-actions {
.app-main .topo-sidebar__icon-btn:disabled,
.topo-sidebar__icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.topo-tree-search {
position: relative;
margin: 0 0 4px;
}
.topo-tree-search__bar {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.topo-tree-search__bar > .input {
flex: 1 1 auto;
min-width: 0;
}
.topo-tree-search__panel {
position: absolute;
z-index: 40;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: min(360px, 50vh);
overflow: auto;
padding: 4px;
border: 1px solid #b8c4d2;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.topo-tree-search__item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px;
border-radius: 6px;
}
.topo-tree-search__item + .topo-tree-search__item {
margin-top: 2px;
border-top: 1px solid #eef2f6;
padding-top: 4px;
}
.topo-tree-search__ne {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 0 8px;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: transparent;
text-align: left;
cursor: pointer;
color: inherit;
}
.topo-tree-search__ne:hover {
background: #f1f5f9;
}
.topo-tree-search__name {
grid-column: 1;
font-size: 12px;
font-weight: 600;
color: var(--nm-ink, #0f172a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topo-tree-search__ip {
grid-column: 1;
font-size: 11px;
}
.topo-tree-search__meta {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
font-size: 11px;
white-space: nowrap;
}
.topo-tree-search__views {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 0 6px 4px;
}
.topo-tree-search__view {
max-width: 100%;
padding: 2px 8px;
border: 1px solid #d0dae6;
border-radius: 999px;
background: #f8fafc;
font-size: 11px;
color: var(--nm-brand, #1d4ed8);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topo-tree-search__view:hover {
background: #e8eef8;
border-color: #9eb6d8;
}
.topo-tree-search__hint {
margin: 0;
padding: 8px;
font-size: 12px;
}
.topo-palette-source {
@ -3859,9 +3991,156 @@ pre {
gap: 14px;
align-content: start;
padding: 16px 18px 20px;
background-image:
radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.22) 1px, transparent 0);
background-size: 18px 18px;
background: #eef2f7;
}
.topo-browser__grid--regions {
grid-template-columns: repeat(auto-fill, minmax(148px, 160px));
gap: 20px 22px;
padding: 24px 22px 32px;
background: #e8eef6;
justify-content: start;
}
/* Beat .app-main button { background:#fff; color:#1f2937; border } */
.app-main .topo-region-hex,
.topo-region-hex {
width: 140px;
height: 156px;
box-sizing: border-box;
border: 0 !important;
border-radius: 0;
box-shadow: none;
padding: 34px 18px 30px;
margin: 0;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
cursor: pointer;
color: #fff !important;
transition: transform 0.15s ease, filter 0.15s ease;
}
.app-main .topo-region-hex:hover,
.topo-region-hex:hover,
.app-main .topo-region-hex.is-hot,
.topo-region-hex.is-hot {
transform: translateY(-2px) scale(1.03);
filter: brightness(1.06);
}
.app-main .topo-region-hex.is-selected,
.topo-region-hex.is-selected,
.app-main .topo-region-hex.is-hot,
.topo-region-hex.is-hot {
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.92),
0 0 0 6px rgba(26, 86, 168, 0.55);
z-index: 1;
}
.app-main .topo-region-hex.is-selected,
.topo-region-hex.is-selected {
filter: brightness(1.08);
transform: translateY(-2px) scale(1.04);
}
.app-main .topo-region-hex--tone-0,
.topo-region-hex--tone-0 {
background: linear-gradient(160deg, #5b9cf0 0%, #1a56a8 52%, #0f766e 100%) !important;
}
.app-main .topo-region-hex--tone-1,
.topo-region-hex--tone-1 {
background: linear-gradient(160deg, #67e8f9 0%, #0284c7 48%, #1d4ed8 100%) !important;
}
.app-main .topo-region-hex--tone-2,
.topo-region-hex--tone-2 {
background: linear-gradient(160deg, #5eead4 0%, #0d9488 50%, #115e59 100%) !important;
}
.app-main .topo-region-hex--tone-3,
.topo-region-hex--tone-3 {
background: linear-gradient(160deg, #fcd34d 0%, #f59e0b 46%, #b45309 100%) !important;
}
.app-main .topo-region-hex--tone-4,
.topo-region-hex--tone-4 {
background: linear-gradient(160deg, #cbd5e1 0%, #64748b 48%, #1e3a5f 100%) !important;
}
.app-main .topo-region-hex--tone-physical,
.topo-region-hex--tone-physical {
background: linear-gradient(160deg, #fcd34d 0%, #f59e0b 42%, #b45309 100%) !important;
}
.app-main .topo-region-hex--add,
.topo-region-hex--add {
background: linear-gradient(160deg, #f8fafc 0%, #dbe7f5 100%) !important;
color: #1a56a8 !important;
}
.app-main .topo-region-hex--add:hover,
.topo-region-hex--add:hover {
background: linear-gradient(160deg, #ffffff 0%, #cfe0f4 100%) !important;
color: #0f2744 !important;
}
.app-main .topo-region-hex--add:disabled,
.topo-region-hex--add:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.topo-region-hex__icon {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.22);
color: #fff;
flex: 0 0 auto;
}
.topo-region-hex--add .topo-region-hex__icon {
background: rgba(26, 86, 168, 0.12);
color: #1a56a8;
}
.topo-region-hex__name {
max-width: 100%;
font-size: 14px;
font-weight: 700;
line-height: 1.25;
color: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topo-region-hex__title {
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
gap: 4px 6px;
max-width: 100%;
min-width: 0;
}
.topo-region-hex__title .topo-region-hex__name {
max-width: 70%;
}
.topo-region-hex__meta {
display: inline;
flex: 0 0 auto;
font-size: 11px;
font-weight: 600;
line-height: 1.25;
color: rgba(255, 255, 255, 0.95);
white-space: nowrap;
}
.topo-region-hex--add .topo-region-hex__meta {
color: #3d6f9c;
}
.topo-region-hex__plus {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 22px;
line-height: 1;
background: rgba(26, 86, 168, 0.12);
color: #1a56a8;
flex: 0 0 auto;
}
.topo-map-card {
display: flex;
@ -3988,22 +4267,51 @@ pre {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
gap: 2px 6px;
min-width: 0;
font-size: 13px;
font-weight: 550;
letter-spacing: -0.01em;
line-height: 1.3;
color: var(--nm-brand, #0f2744);
}
.app-main .topo-breadcrumb__link,
.topo-breadcrumb__link {
border: 0;
background: transparent;
color: var(--nm-accent, #2563eb);
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: #5b6b7c !important;
cursor: pointer;
padding: 0;
padding: 0 !important;
margin: 0;
border-radius: 0 !important;
font: inherit;
font-weight: 550;
line-height: inherit;
width: auto;
}
.app-main .topo-breadcrumb__link:hover,
.topo-breadcrumb__link:hover {
color: var(--nm-accent, #1a56a8) !important;
background: transparent !important;
text-decoration: underline;
text-underline-offset: 2px;
}
.topo-breadcrumb__sep {
opacity: 0.45;
color: #94a3b8;
opacity: 1;
font-weight: 400;
user-select: none;
}
.topo-breadcrumb__current {
font-weight: 600;
display: inline;
padding: 0;
border-radius: 0;
font: inherit;
font-weight: 700;
color: var(--nm-accent, #1a56a8);
background: transparent;
box-shadow: none;
}
.topo-toolbar__level {
margin-left: 8px;
@ -4277,7 +4585,7 @@ pre {
.topo-region-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 2px;
padding: 2px 0;
}
@ -4285,49 +4593,216 @@ pre {
margin: 0;
width: 100%;
min-width: 0;
padding: 2px 0 4px;
}
.topo-region-list__row {
align-items: stretch;
align-items: center;
}
.topo-region-list__block.is-active > .topo-region-list__row {
border-color: var(--nm-accent);
background: var(--nm-nav-active);
box-shadow: inset 2px 0 0 var(--nm-accent);
/* Flat list rows — no boxed cards */
.topo-region-list .topo-map-list__row {
border: 0;
border-radius: 8px;
background: transparent;
box-shadow: none;
overflow: visible;
gap: 2px;
padding-right: 2px;
transition: background 0.12s ease;
}
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions {
border-left-color: #9bb6d9;
.topo-region-list .topo-map-list__row:hover,
.topo-region-list__block.is-hot > .topo-region-list__row,
.topo-region-list__maps > li.is-hot > .topo-map-list__row {
background: rgba(255, 255, 255, 0.78);
}
/* Selected = deep wash + left accent */
.topo-region-list__block.is-active > .topo-region-list__row,
.topo-region-list__maps > li.is-active > .topo-map-list__row {
border: 0;
background: #8eb6e3;
box-shadow: inset 3px 0 0 #0f3f7a;
}
.topo-region-list__block.is-branch-active > .topo-region-list__row {
border: 0;
background: rgba(142, 182, 227, 0.35);
box-shadow: none;
}
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions,
.topo-region-list__maps > li.is-active > .topo-map-list__row .topo-map-list__actions {
border-left: 0;
}
.topo-region-list__glyph {
display: inline-flex;
align-items: center;
margin-right: 5px;
vertical-align: -2px;
justify-content: center;
width: 22px;
height: 22px;
margin-right: 2px;
border-radius: 6px;
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
flex: 0 0 auto;
}
.topo-region-list__maps .topo-region-list__glyph {
background: rgba(26, 86, 168, 0.1);
color: #1a56a8;
}
.topo-region-list__maps {
margin: 4px 0 0 18px;
padding: 0;
position: relative;
margin: 2px 0 0;
padding: 2px 0 2px 0;
overflow: visible;
flex: none;
}
.topo-region-list__maps::before {
content: "";
position: absolute;
left: 18px;
top: 0;
bottom: 6px;
width: 1px;
background: rgba(148, 163, 184, 0.55);
}
.topo-region-list__maps > li {
margin: 0 0 4px;
margin: 0;
width: 100%;
position: relative;
}
.topo-region-list__maps > li + li {
margin-top: 1px;
}
.topo-region-list__maps .topo-map-list__row {
width: 100%;
}
.app-main .topo-region-list .topo-map-list__item,
.topo-region-list .topo-map-list__item {
padding: 8px 6px 8px 8px;
border-radius: 8px;
}
.app-main .topo-region-list__maps .topo-map-list__item,
.topo-region-list__maps .topo-map-list__item {
padding: 7px 6px 7px 36px;
}
.topo-region-list .topo-map-list__item:hover,
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__item,
.topo-region-list__block.is-branch-active > .topo-region-list__row .topo-map-list__item,
.topo-region-list__maps > li.is-active .topo-map-list__item,
.topo-region-list__maps > li.is-active .topo-map-list__item:hover {
background: transparent !important;
}
.topo-region-list .topo-map-list__actions {
opacity: 0;
pointer-events: none;
border-left: 0;
gap: 4px;
padding-right: 4px;
align-items: center;
transition: opacity 0.12s ease;
}
/* Edit/delete only while the row itself is hovered */
.topo-region-list .topo-map-list__row:hover .topo-map-list__actions {
opacity: 1;
pointer-events: auto;
}
.app-main .topo-region-list .topo-map-list__icon,
.topo-region-list .topo-map-list__icon {
width: 24px;
height: 24px;
min-height: 24px;
border-radius: 5px;
color: #475569;
background: transparent !important;
border: 0 !important;
}
.app-main .topo-region-list .topo-map-list__icon:hover:not(:disabled),
.topo-region-list .topo-map-list__icon:hover:not(:disabled) {
background: rgba(15, 39, 68, 0.12) !important;
color: var(--nm-brand);
}
.topo-region-list .topo-map-list__name {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
.topo-region-list .topo-map-list__title {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
color: var(--nm-brand, #0f2744);
}
.topo-region-list__maps .topo-map-list__title {
font-size: 12px;
font-weight: 600;
}
.topo-region-list .topo-map-list__count {
flex: 0 0 auto;
margin-left: 4px;
padding: 0;
border-radius: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: #64748b;
background: transparent;
box-shadow: none;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
}
.topo-region-list__maps .topo-map-list__count {
color: #64748b;
background: transparent;
}
.topo-region-list__block.is-active .topo-map-list__count,
.topo-region-list__maps > li.is-active .topo-map-list__count {
background: transparent;
color: #0a274f;
}
.topo-region-list__block.is-hot:not(.is-active) .topo-map-list__count,
.topo-region-list__maps > li.is-hot:not(.is-active) .topo-map-list__count {
background: transparent;
color: #1a56a8;
}
.topo-region-list__block.is-active .topo-map-list__title,
.topo-region-list__maps > li.is-active .topo-map-list__title {
color: #0a274f;
font-weight: 700;
}
.topo-region-list__maps > li.is-active .topo-region-list__glyph {
background: #0f3f7a;
color: #fff;
}
.topo-map-list li,
@ -4361,6 +4836,29 @@ pre {
box-shadow: inset 2px 0 0 var(--nm-accent);
}
.topo-region-list__block.is-active > .topo-map-list__row,
.topo-region-list__maps > li.is-active > .topo-map-list__row {
border: 0 !important;
background: #8eb6e3 !important;
box-shadow: inset 3px 0 0 #0f3f7a !important;
}
.topo-region-list__block.is-active > .topo-map-list__row:hover,
.topo-region-list__maps > li.is-active > .topo-map-list__row:hover {
background: #7aa8db !important;
}
.topo-region-list__block.is-branch-active > .topo-map-list__row {
border: 0 !important;
background: rgba(142, 182, 227, 0.35) !important;
box-shadow: none !important;
}
.topo-region-list__block.is-hot:not(.is-active):not(.is-branch-active) > .topo-map-list__row,
.topo-region-list__maps > li.is-hot:not(.is-active) > .topo-map-list__row {
background: #c5daf3 !important;
}
.topo-palette > li.is-on-canvas .topo-palette__row {
background: #eef2f7;
border-color: #d7dee8;

View file

@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ReactFlow,
@ -39,6 +39,7 @@ import {
patchTopologyPositions,
projectTopologyNeighbors,
removeTopologyViewNodes,
searchFabricNodes,
startLldpDiscover,
updateTopologyFolder,
updateTopologyMap,
@ -49,6 +50,7 @@ import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { openOrFocusModule } from "../utils/moduleWindows";
import type {
FabricNodeSearchHit,
ManagedNeItem,
TopologyDiscoverJob,
TopologyDiscoverNeResult,
@ -70,10 +72,6 @@ import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/tool
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
function kindLabelKey(kind: string): string {
return String(kind) === "physical" ? "topology.kindPhysical" : "topology.kindCustom";
}
function findViewInRegion(
regions: TopologyTreeFolderItem[],
viewId: string,
@ -127,36 +125,15 @@ function FullscreenIcon({ exit }: { exit?: boolean }) {
/** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */
const SEP = " / ";
function ChevronIcon({ dir }: { dir: "left" | "right" | "down" }) {
const d =
dir === "left" ? "M15 6l-6 6 6 6" : dir === "down" ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6";
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" d={d} />
</svg>
);
}
/** Left-panel collapse / expand control (icon-only sidebar toggle). */
/** Left-panel collapse / expand control (simple chevron). */
function SidebarFoldIcon({ expand }: { expand?: boolean }) {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<rect
x="3.5"
y="4.5"
width="17"
height="15"
rx="2"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
/>
<path d="M9.5 4.5v15" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path
d={expand ? "M13.5 9.5L17 12l-3.5 2.5" : "M16.5 9.5L13 12l3.5 2.5"}
d={expand ? "M9 6l6 6-6 6" : "M15 6l-6 6 6 6"}
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
@ -632,6 +609,8 @@ export function TopologyPage() {
// mapId set → fabric canvas; otherwise browse directory (region or view folder).
const [mapId, setMapId] = useState<string>("");
const [selectedFolderId, setSelectedFolderId] = useState<string>("");
/** Hover sync key between left tree and right hex browser: `region:<id>` | `view:<id>`. */
const [hotBrowseKey, setHotBrowseKey] = useState("");
const [pendingHighlightNe, setPendingHighlightNe] = useState("");
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
try {
@ -643,6 +622,10 @@ export function TopologyPage() {
});
const [keyword, setKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [treeNeQuery, setTreeNeQuery] = useState("");
const [debouncedTreeNeQuery, setDebouncedTreeNeQuery] = useState("");
const [treeSearchOpen, setTreeSearchOpen] = useState(false);
const treeSearchRef = useRef<HTMLDivElement | null>(null);
const [dirty, setDirty] = useState(false);
const [historyTick, setHistoryTick] = useState(0);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
@ -755,6 +738,17 @@ export function TopologyPage() {
queryFn: fetchTopologyTree,
});
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200);
return () => window.clearTimeout(timer);
}, [treeNeQuery]);
const treeNeSearchQuery = useQuery({
queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1),
queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }),
enabled: debouncedTreeNeQuery.length >= 1,
});
const graphQuery = useQuery({
queryKey: queryKeys.topologyGraph(mapId),
queryFn: () => fetchTopologyGraph(mapId),
@ -815,11 +809,9 @@ export function TopologyPage() {
}
return;
}
if (!selectedFolderId || selectedFolderId === treeRoot.id) {
setSelectedFolderId(regions[0].id);
setExpandedIds((prev) =>
prev[regions[0].id] === undefined ? { ...prev, [regions[0].id]: true } : prev,
);
// Keep root (all regions) as the default landing view — do not auto-pick regions[0].
if (selectedFolderId && !regions.some((r) => r.id === selectedFolderId)) {
setSelectedFolderId("");
}
}, [mapId, selectedFolderId, treeRoot, regions]);
@ -852,6 +844,15 @@ export function TopologyPage() {
[confirmDiscardIfDirty, clearDirty, setNodes, setEdges],
);
const goRoot = useCallback(() => {
if (!confirmDiscardIfDirty()) return;
setMapId("");
setSelectedFolderId("");
clearDirty();
setNodes([]);
setEdges([]);
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]);
const goCanvas = useCallback(
(viewId: string, folderId?: string) => {
if (!confirmDiscardIfDirty()) return;
@ -1801,6 +1802,70 @@ export function TopologyPage() {
[focusNode],
);
const jumpToTreeSearchHit = useCallback(
(
hit: FabricNodeSearchHit,
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
) => {
const views = hit.views || [];
const onCurrent =
Boolean(mapId) &&
(views.some((v) => v.view_id === mapId) || nodes.some((n) => n.id === hit.id));
if (onCurrent && mapId && (!view || view.view_id === mapId)) {
if (nodes.some((n) => n.id === hit.id)) {
locateNode(hit.id);
} else {
setPendingHighlightNe(hit.id);
}
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
return;
}
const target =
view ||
views.find((v) => String(v.kind) === "physical") ||
views[0] ||
null;
if (!target) {
showError(t("topology.treeSearchNotOnMap"));
return;
}
if (!confirmDiscardIfDirty()) return;
const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id;
if (regionId) {
setSelectedFolderId(regionId);
setExpandedIds((p) => ({ ...p, [regionId]: true }));
}
setMapId(target.view_id);
setPendingHighlightNe(hit.id);
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
},
[
mapId,
nodes,
locateNode,
showError,
t,
confirmDiscardIfDirty,
regions,
],
);
useEffect(() => {
if (!treeSearchOpen) return;
const onDoc = (e: MouseEvent) => {
const el = treeSearchRef.current;
if (el && e.target instanceof Element && !el.contains(e.target)) {
setTreeSearchOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [treeSearchOpen]);
useEffect(() => {
if (!pendingHighlightNe || !canvasMode || !nodes.length) return;
const nodeId = pendingHighlightNe;
@ -1922,7 +1987,8 @@ export function TopologyPage() {
const titleText = useMemo(() => {
if (canvasMode) return activeLeafName || t("topology.selectMap");
return regionDisplayName(activeRegion) || t("topology.selectRegion");
if (activeRegion) return regionDisplayName(activeRegion);
return t("topology.rootName");
}, [canvasMode, activeLeafName, activeRegion, t]);
const onCanvasManagedIds = useMemo(
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
@ -2002,12 +2068,23 @@ export function TopologyPage() {
) : (
<>
<div className="topo-sidebar__section">
<div className="topo-sidebar__head">
<strong>{t("topology.tree")}</strong>
<div className="topo-sidebar__head-actions">
<div className="topo-tree-search" ref={treeSearchRef}>
<div className="topo-tree-search__bar">
<input
className="input"
type="search"
value={treeNeQuery}
placeholder={t("topology.treeSearchPh")}
aria-label={t("topology.treeSearch")}
onChange={(e) => {
setTreeNeQuery(e.target.value);
setTreeSearchOpen(true);
}}
onFocus={() => setTreeSearchOpen(true)}
/>
<button
type="button"
className="btn btn--sm btn--ghost topo-icon-btn"
className="topo-sidebar__icon-btn"
onClick={promptNewRegion}
disabled={createRegionMut.isPending || !treeRoot}
title={t("topology.newRegion")}
@ -2017,7 +2094,7 @@ export function TopologyPage() {
</button>
<button
type="button"
className="btn btn--sm btn--ghost topo-icon-btn"
className="topo-sidebar__icon-btn"
title={t("topology.collapseSidebar")}
aria-label={t("topology.collapseSidebar")}
onClick={() => setSidebarCollapsed(true)}
@ -2025,6 +2102,72 @@ export function TopologyPage() {
<SidebarFoldIcon />
</button>
</div>
{treeSearchOpen && treeNeQuery.trim() ? (
<div className="topo-tree-search__panel" role="listbox">
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? (
<p className="topo-tree-search__hint muted">…</p>
) : !(treeNeSearchQuery.data?.items || []).length ? (
<p className="topo-tree-search__hint muted">{t("topology.treeSearchEmpty")}</p>
) : (
(treeNeSearchQuery.data?.items || []).map((hit) => {
const views = hit.views || [];
const title = hit.name || hit.ip || hit.id.slice(0, 8);
return (
<div key={hit.id} className="topo-tree-search__item">
<button
type="button"
className="topo-tree-search__ne"
onClick={() => jumpToTreeSearchHit(hit)}
title={
views.length
? t("topology.locateOnCanvas")
: t("topology.treeSearchNotOnMap")
}
>
<span className="topo-tree-search__name">{title}</span>
{hit.ip ? <span className="topo-tree-search__ip muted">{hit.ip}</span> : null}
{!views.length ? (
<span className="topo-tree-search__meta muted">
{t("topology.treeSearchNoViews")}
</span>
) : (
<span className="topo-tree-search__meta muted">
{t("topology.treeSearchViewCount").replace(
"{{count}}",
String(views.length),
)}
</span>
)}
</button>
{views.length > 1 ? (
<div className="topo-tree-search__views">
{views.map((v) => (
<button
key={`${hit.id}-${v.view_id}`}
type="button"
className="topo-tree-search__view"
onClick={() => jumpToTreeSearchHit(hit, v)}
title={`${v.folder_name ? `${v.folder_name} / ` : ""}${v.view_name}`}
>
{v.view_name || v.view_id.slice(0, 8)}
</button>
))}
</div>
) : null}
</div>
);
})
)}
{(treeNeSearchQuery.data?.total || 0) > 30 ? (
<p className="topo-tree-search__hint muted">
{t("topology.treeSearchTruncated").replace(
"{{total}}",
String(treeNeSearchQuery.data?.total || 0),
)}
</p>
) : null}
</div>
) : null}
</div>
{!treeRoot ? (
<p className="panel__hint">{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}</p>
@ -2033,30 +2176,56 @@ export function TopologyPage() {
) : (
<ul className="topo-map-list topo-region-list">
{regions.map((region) => {
const open = expandedIds[region.id] ?? true;
const open = expandedIds[region.id] ?? false;
const regionActive = selectedFolderId === region.id && !mapId;
const regionBranch = selectedFolderId === region.id && Boolean(mapId);
const regionHot = hotBrowseKey === `region:${region.id}`;
return (
<li
key={region.id}
className={`topo-region-list__block${regionActive ? " is-active" : ""}`}
className={`topo-region-list__block${
regionActive ? " is-active" : regionBranch ? " is-branch-active" : ""
}${regionHot ? " is-hot" : ""}`}
>
<div className="topo-map-list__row topo-region-list__row">
<div
className="topo-map-list__row topo-region-list__row"
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
}
>
<button
type="button"
className="topo-map-list__item"
onClick={() => goRegion(region.id)}
onClick={() => {
if (!confirmDiscardIfDirty()) return;
const nextOpen = !(expandedIds[region.id] ?? false);
setMapId("");
setSelectedFolderId(region.id);
setExpandedIds((p) => ({ ...p, [region.id]: nextOpen }));
clearDirty();
setNodes([]);
setEdges([]);
}}
title={
open
? t("topology.collapseRegion")
: t("topology.expandRegion")
}
>
<span className="topo-map-list__name">
<span className="topo-region-list__glyph" aria-hidden="true">
<RegionGlyph size={14} />
</span>
{regionDisplayName(region)}
</span>
<span className="topo-map-list__meta">
{t("topology.regionNodeHint").replace(
"{{count}}",
String(region.views.length),
)}
<span className="topo-map-list__title">
{regionDisplayName(region)}
</span>
<span className="topo-map-list__count">
{t("topology.regionNodeHint").replace(
"{{count}}",
String(region.views.length),
)}
</span>
</span>
</button>
<div className="topo-map-list__actions">
@ -2086,34 +2255,27 @@ export function TopologyPage() {
>
<CloseIcon />
</button>
<button
type="button"
className="topo-map-list__icon"
title={
open
? t("topology.collapseRegion")
: t("topology.expandRegion")
}
aria-label={
open
? t("topology.collapseRegion")
: t("topology.expandRegion")
}
onClick={() =>
setExpandedIds((p) => ({ ...p, [region.id]: !open }))
}
>
<ChevronIcon dir={open ? "down" : "right"} />
</button>
</div>
</div>
{open ? (
<ul className="topo-map-list topo-region-list__maps">
{(region.views || []).map((v) => {
const isPhysical = String(v.kind) === "physical";
const viewHot = hotBrowseKey === `view:${v.id}`;
return (
<li key={v.id} className={mapId === v.id ? "is-active" : ""}>
<div className="topo-map-list__row">
<li
key={v.id}
className={`${mapId === v.id ? "is-active" : ""}${
viewHot ? " is-hot" : ""
}`}
>
<div
className="topo-map-list__row"
onMouseEnter={() => setHotBrowseKey(`view:${v.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
}
>
<button
type="button"
className="topo-map-list__item"
@ -2133,13 +2295,13 @@ export function TopologyPage() {
size={13}
/>
</span>
{v.name}
{mapId === v.id && dirty ? " *" : ""}
</span>
<span className="topo-map-list__meta">
{t(kindLabelKey(String(v.kind)))}
{" · "}
{v.node_count || 0}N
<span className="topo-map-list__title">
{v.name}
{mapId === v.id && dirty ? " *" : ""}
</span>
<span className="topo-map-list__count">
{v.node_count || 0}N
</span>
</span>
</button>
<div className="topo-map-list__actions">
@ -2197,17 +2359,25 @@ export function TopologyPage() {
<div className="topo-toolbar__row">
<div className="topo-toolbar__title">
<div className="topo-breadcrumb">
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRoot()}
>
{t("topology.rootName")}
</button>
{activeRegion ? (
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRegion(activeRegion.id)}
>
{regionDisplayName(activeRegion)}
</button>
) : (
<span className="topo-breadcrumb__current">{t("topology.selectRegion")}</span>
)}
<>
<span className="topo-breadcrumb__sep">/</span>
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRegion(activeRegion.id)}
>
{regionDisplayName(activeRegion)}
</button>
</>
) : null}
{activeView ? (
<>
<span className="topo-breadcrumb__sep">/</span>
@ -2597,9 +2767,18 @@ export function TopologyPage() {
.replace("{{failed}}", String(discoverSummary.failed))}
</p>
<p className="panel__hint">
<Link className="btn btn--sm btn--ghost" to="/network/topology/lldp">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() =>
openOrFocusModule({
moduleId: "network",
path: "/network/topology/lldp",
})
}
>
{t("topology.discoverGoLldp")}
</Link>
</button>
</p>
</>
) : null}
@ -2610,90 +2789,147 @@ export function TopologyPage() {
<div className="topo-browser" aria-label={t("topology.browserTitle")}>
<div className="topo-browser__head">
<div>
<strong>{titleText}</strong>
{activeRegion ? (
<div className="topo-breadcrumb">
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRoot()}
>
{t("topology.rootName")}
</button>
<span className="topo-breadcrumb__sep">/</span>
<span className="topo-breadcrumb__current">{regionDisplayName(activeRegion)}</span>
</div>
) : (
<strong>{titleText}</strong>
)}
<p className="topo-browser__sub">
{activeRegion
? t("topology.browserRegionSub").replace(
"{{count}}",
String(browseEntries.length),
)
: t("topology.regionBrowseHint")}
: t("topology.browserRegionsSub").replace(
"{{count}}",
String(regions.length),
)}
</p>
</div>
</div>
{!activeRegion ? (
<div className="topo-browser__empty">
<span className="topo-browser__empty-icon" aria-hidden="true">
<RegionGlyph size={36} />
</span>
<p>{t("topology.selectRegion")}</p>
</div>
regions.length === 0 ? (
<div className="topo-browser__empty">
<span className="topo-browser__empty-icon" aria-hidden="true">
<RegionGlyph size={36} />
</span>
<p>{t("topology.emptyMaps")}</p>
<button
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
disabled={createRegionMut.isPending || !treeRoot}
>
{t("topology.newRegion")}
</button>
</div>
) : (
<div className="topo-browser__grid topo-browser__grid--regions">
{regions.map((region, idx) => (
<button
key={region.id}
type="button"
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
selectedFolderId === region.id ? " is-selected" : ""
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
}
onClick={() => goRegion(region.id)}
title={t("topology.openRegion")}
>
<span className="topo-region-hex__icon" aria-hidden="true">
<RegionGlyph size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{regionDisplayName(region)}</span>
<span className="topo-region-hex__meta">
{t("topology.regionNodeHint").replace(
"{{count}}",
String(region.views?.length || 0),
)}
</span>
</span>
</button>
))}
<button
type="button"
className="topo-region-hex topo-region-hex--add"
onClick={promptNewRegion}
disabled={createRegionMut.isPending || !treeRoot}
title={t("topology.newRegion")}
>
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span className="topo-region-hex__name">{t("topology.newRegion")}</span>
</button>
</div>
)
) : browseEntries.length === 0 ? (
<div className="topo-browser__empty">
<p>{t("topology.browserEmptyMaps")}</p>
<div className="topo-browser__grid topo-browser__grid--regions">
<button
type="button"
className="btn btn--sm"
className="topo-region-hex topo-region-hex--add"
onClick={() => promptNewCustom(activeRegion.id)}
title={t("topology.newCustom")}
>
{t("topology.newCustom")}
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
</button>
</div>
) : (
<div className="topo-browser__grid">
{browseEntries.map((v) => {
<div className="topo-browser__grid topo-browser__grid--regions">
{browseEntries.map((v, idx) => {
const isPhysical = String(v.kind) === "physical";
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
return (
<article
<button
key={v.id}
className={`topo-map-card${isPhysical ? " is-physical" : ""}`}
type="button"
className={`topo-region-hex topo-region-hex--tone-${tone}${
mapId === v.id ? " is-selected" : ""
}${hotBrowseKey === `view:${v.id}` ? " is-hot" : ""}`}
onMouseEnter={() => setHotBrowseKey(`view:${v.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
}
onClick={() => goCanvas(v.id, activeRegion.id)}
title={t("topology.openMap")}
>
<button
type="button"
className="topo-map-card__body"
onClick={() => goCanvas(v.id, activeRegion.id)}
onDoubleClick={() => goCanvas(v.id, activeRegion.id)}
>
<span className="topo-map-card__icon" aria-hidden="true">
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={28} />
</span>
<span className={`topo-map-card__kind${isPhysical ? " is-physical" : ""}`}>
{t(kindLabelKey(String(v.kind)))}
</span>
<span className="topo-map-card__name">{v.name}</span>
<span className="topo-map-card__meta">
{t("topology.layerNodeHint").replace("{{count}}", String(v.node_count || 0))}
{v.updated_at ? ` · ${formatUpdatedAt(v.updated_at)}` : ""}
</span>
</button>
<div className="topo-map-card__actions">
<button
type="button"
className="btn btn--sm"
onClick={() => goCanvas(v.id, activeRegion.id)}
>
{t("topology.openMap")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => promptRenameMap(v.id, v.name)}
>
{t("topology.rename")}
</button>
</div>
</article>
<span className="topo-region-hex__icon" aria-hidden="true">
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{v.name}</span>
<span className="topo-region-hex__meta">{v.node_count || 0}N</span>
</span>
</button>
);
})}
<button
type="button"
className="topo-map-card topo-map-card--add"
className="topo-region-hex topo-region-hex--add"
onClick={() => promptNewCustom(activeRegion.id)}
title={t("topology.newCustom")}
>
<span className="topo-map-card__add-plus" aria-hidden="true">
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span>{t("topology.newCustom")}</span>
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
</button>
</div>
)}