mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
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:
parent
3908b78dbb
commit
cb8e0d23c5
6 changed files with 1520 additions and 364 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "返回区域",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue