mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 19:33:20 +08:00
Improve topology canvas UX and vendor NE icons.
Replace the top selection bar with a context menu, tighten map/palette cards and fullscreen controls, and tint the router asset by vendor (ZTE keeps default blue). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
658c19e457
commit
2f1c33924e
5 changed files with 656 additions and 294 deletions
BIN
web/public/topo/ne-router.png
Normal file
BIN
web/public/topo/ne-router.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
|
|
@ -692,12 +692,14 @@ const en = {
|
||||||
save: "Save",
|
save: "Save",
|
||||||
saving: "Saving…",
|
saving: "Saving…",
|
||||||
saved: "Topology saved",
|
saved: "Topology saved",
|
||||||
discover: "Discover links",
|
discover: "Discover all links",
|
||||||
discovering: "Discovering…",
|
discovering: "Discovering…",
|
||||||
|
discoverOne: "Discover links",
|
||||||
discovered: "Discovery done: +{{added}} updated {{updated}} missing {{stale}}",
|
discovered: "Discovery done: +{{added}} updated {{updated}} missing {{stale}}",
|
||||||
discoverFail: "Discovery failed: {{detail}}",
|
discoverFail: "Discovery failed: {{detail}}",
|
||||||
|
discoverOneNeedNe: "This node is not linked to a scannable NE",
|
||||||
selectMap: "Select or create a topology map",
|
selectMap: "Select or create a topology map",
|
||||||
canvasHint: "Add NEs from the left; blue dashed=found, red dashed=missing (deletable)",
|
canvasHint: "Add NEs from the left; blue dashed=found, red dashed=missing (deletable); right-click a node for actions",
|
||||||
selected: "Selected",
|
selected: "Selected",
|
||||||
selectedEdge: "Selected edge",
|
selectedEdge: "Selected edge",
|
||||||
openWebcrt: "Open terminal",
|
openWebcrt: "Open terminal",
|
||||||
|
|
@ -709,6 +711,8 @@ const en = {
|
||||||
edgeDiscovered: "Discovered",
|
edgeDiscovered: "Discovered",
|
||||||
edgeStale: "Missing",
|
edgeStale: "Missing",
|
||||||
fit: "Fit view",
|
fit: "Fit view",
|
||||||
|
fullscreen: "Fullscreen",
|
||||||
|
exitFullscreen: "Exit fullscreen",
|
||||||
display: "Display",
|
display: "Display",
|
||||||
hideIp: "Hide IP",
|
hideIp: "Hide IP",
|
||||||
hideVendor: "Hide vendor",
|
hideVendor: "Hide vendor",
|
||||||
|
|
|
||||||
|
|
@ -689,12 +689,14 @@ const zh = {
|
||||||
save: "保存",
|
save: "保存",
|
||||||
saving: "保存中…",
|
saving: "保存中…",
|
||||||
saved: "拓扑已保存",
|
saved: "拓扑已保存",
|
||||||
discover: "发现链路",
|
discover: "发现全量链路",
|
||||||
discovering: "发现中…",
|
discovering: "发现中…",
|
||||||
|
discoverOne: "发现链路",
|
||||||
discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}",
|
discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}",
|
||||||
discoverFail: "发现失败:{{detail}}",
|
discoverFail: "发现失败:{{detail}}",
|
||||||
|
discoverOneNeedNe: "该节点未关联可扫描网元",
|
||||||
selectMap: "请选择或新建一张拓扑图",
|
selectMap: "请选择或新建一张拓扑图",
|
||||||
canvasHint: "从左侧添加网元,拖动节点并连线;蓝虚线=发现链路,红虚线=本次未发现(可删)",
|
canvasHint: "从左侧添加网元,拖动节点并连线;蓝虚线=发现链路,红虚线=本次未发现(可删);右键节点可操作",
|
||||||
selected: "已选节点",
|
selected: "已选节点",
|
||||||
selectedEdge: "已选链路",
|
selectedEdge: "已选链路",
|
||||||
openWebcrt: "打开终端",
|
openWebcrt: "打开终端",
|
||||||
|
|
@ -706,6 +708,8 @@ const zh = {
|
||||||
edgeDiscovered: "发现",
|
edgeDiscovered: "发现",
|
||||||
edgeStale: "未发现",
|
edgeStale: "未发现",
|
||||||
fit: "适应画布",
|
fit: "适应画布",
|
||||||
|
fullscreen: "全屏显示",
|
||||||
|
exitFullscreen: "退出全屏",
|
||||||
display: "显示选项",
|
display: "显示选项",
|
||||||
hideIp: "隐藏 IP",
|
hideIp: "隐藏 IP",
|
||||||
hideVendor: "隐藏厂商",
|
hideVendor: "隐藏厂商",
|
||||||
|
|
|
||||||
|
|
@ -688,23 +688,30 @@ pre {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main .help-hint__trigger,
|
||||||
.help-hint__trigger {
|
.help-hint__trigger {
|
||||||
width: 18px;
|
width: auto;
|
||||||
height: 18px;
|
height: auto;
|
||||||
padding: 0;
|
min-width: 0;
|
||||||
border: 1px solid #cbd5e1;
|
padding: 0 2px;
|
||||||
border-radius: 50%;
|
border: none;
|
||||||
background: #f8fafc;
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-main .help-hint__trigger:hover,
|
||||||
|
.app-main .help-hint__trigger:focus-visible,
|
||||||
.help-hint__trigger:hover,
|
.help-hint__trigger:hover,
|
||||||
.help-hint__trigger:focus-visible {
|
.help-hint__trigger:focus-visible {
|
||||||
border-color: #1565c0;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
color: #1565c0;
|
color: #1565c0;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
@ -1931,6 +1938,7 @@ pre {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-sidebar__section--grow {
|
.topo-sidebar__section--grow {
|
||||||
|
|
@ -1983,28 +1991,84 @@ pre {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-map-list li {
|
.topo-map-list li,
|
||||||
|
.topo-palette > li {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list li:last-child,
|
||||||
|
.topo-palette > li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list__row,
|
||||||
|
.topo-palette__row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: 4px;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-map-list li.is-active {
|
.topo-map-list li.is-active .topo-map-list__row {
|
||||||
|
border-color: #93c5fd;
|
||||||
background: #e8f1fb;
|
background: #e8f1fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-palette > li.is-on-canvas .topo-palette__row {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-map-list__item,
|
.topo-map-list__item,
|
||||||
.topo-palette__item {
|
.topo-palette__item {
|
||||||
flex: 1;
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 8px;
|
padding: 8px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .topo-map-list__item,
|
||||||
|
.app-main .topo-map-list__icon,
|
||||||
|
.app-main .topo-palette__item {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .topo-map-list__item,
|
||||||
|
.app-main .topo-palette__item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main .topo-map-list__icon {
|
||||||
|
width: 32px;
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-map-list__item:hover,
|
.topo-map-list__item:hover,
|
||||||
|
|
@ -2012,6 +2076,43 @@ pre {
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-map-list li.is-active .topo-map-list__item:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-left: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list li.is-active .topo-map-list__actions {
|
||||||
|
border-left-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
min-height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list__icon:hover:not(:disabled) {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-map-list__icon:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-palette__item:disabled {
|
.topo-palette__item:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
@ -2019,6 +2120,8 @@ pre {
|
||||||
|
|
||||||
.topo-palette__empty {
|
.topo-palette__empty {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-map-list__name,
|
.topo-map-list__name,
|
||||||
|
|
@ -2026,6 +2129,9 @@ pre {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-map-list__meta,
|
.topo-map-list__meta,
|
||||||
|
|
@ -2033,6 +2139,9 @@ pre {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-main {
|
.topo-main {
|
||||||
|
|
@ -2048,18 +2157,21 @@ pre {
|
||||||
|
|
||||||
.topo-toolbar {
|
.topo-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 8px 12px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-toolbar__left {
|
.topo-toolbar__left {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-toolbar__title {
|
.topo-toolbar__title {
|
||||||
|
|
@ -2069,16 +2181,23 @@ pre {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-toolbar__title strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-toolbar__actions {
|
.topo-toolbar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-display-toggles {
|
.topo-display-toggles {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
|
|
@ -2095,20 +2214,49 @@ pre {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-selection {
|
.topo-ctx {
|
||||||
display: flex;
|
position: fixed;
|
||||||
align-items: center;
|
z-index: 40;
|
||||||
justify-content: space-between;
|
min-width: 168px;
|
||||||
gap: 12px;
|
padding: 4px 0;
|
||||||
padding: 8px 12px;
|
margin: 0;
|
||||||
background: #f8fafc;
|
list-style: none;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-selection__actions {
|
.topo-ctx__item {
|
||||||
display: flex;
|
display: block;
|
||||||
flex-wrap: wrap;
|
width: 100%;
|
||||||
gap: 8px;
|
padding: 8px 14px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-ctx__item:hover:not(:disabled) {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-ctx__item:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-ctx__item--danger:hover:not(:disabled) {
|
||||||
|
background: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-ctx__sep {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-discover {
|
.topo-discover {
|
||||||
|
|
@ -2197,6 +2345,29 @@ pre {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas:fullscreen,
|
||||||
|
.topo-canvas.is-fullscreen:fullscreen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Place fullscreen above zoom-in (+); Controls renders custom children last. */
|
||||||
|
.topo-canvas .react-flow__controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas .react-flow__controls-button.topo-fs-control {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas .react-flow__controls-button.topo-fs-control svg {
|
||||||
|
max-width: 14px;
|
||||||
|
max-height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-canvas__empty {
|
.topo-canvas__empty {
|
||||||
|
|
@ -2215,17 +2386,43 @@ pre {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
--topo-vendor: #64748b;
|
/* Default asset is blue (= ZTE). */
|
||||||
|
--topo-vendor: #0277bd;
|
||||||
|
--topo-icon-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-node--cisco { --topo-vendor: #1d4ed8; }
|
.topo-node--cisco {
|
||||||
.topo-node--huawei { --topo-vendor: #c62828; }
|
--topo-vendor: #1d4ed8;
|
||||||
.topo-node--zte { --topo-vendor: #0277bd; }
|
--topo-icon-filter: hue-rotate(-8deg) saturate(1.15);
|
||||||
.topo-node--juniper { --topo-vendor: #2e7d32; }
|
}
|
||||||
.topo-node--nokia { --topo-vendor: #00838f; }
|
.topo-node--huawei {
|
||||||
.topo-node--ericsson { --topo-vendor: #f9a825; }
|
--topo-vendor: #c62828;
|
||||||
.topo-node--h3c { --topo-vendor: #ef6c00; }
|
--topo-icon-filter: hue-rotate(155deg) saturate(1.35);
|
||||||
.topo-node--other { --topo-vendor: #546e7a; }
|
}
|
||||||
|
.topo-node--zte {
|
||||||
|
--topo-vendor: #0277bd;
|
||||||
|
--topo-icon-filter: none;
|
||||||
|
}
|
||||||
|
.topo-node--juniper {
|
||||||
|
--topo-vendor: #2e7d32;
|
||||||
|
--topo-icon-filter: hue-rotate(95deg) saturate(1.2);
|
||||||
|
}
|
||||||
|
.topo-node--nokia {
|
||||||
|
--topo-vendor: #00838f;
|
||||||
|
--topo-icon-filter: hue-rotate(-28deg) saturate(1.1) brightness(0.98);
|
||||||
|
}
|
||||||
|
.topo-node--ericsson {
|
||||||
|
--topo-vendor: #f9a825;
|
||||||
|
--topo-icon-filter: hue-rotate(175deg) saturate(1.45) brightness(1.05);
|
||||||
|
}
|
||||||
|
.topo-node--h3c {
|
||||||
|
--topo-vendor: #ef6c00;
|
||||||
|
--topo-icon-filter: hue-rotate(165deg) saturate(1.4);
|
||||||
|
}
|
||||||
|
.topo-node--other {
|
||||||
|
--topo-vendor: #546e7a;
|
||||||
|
--topo-icon-filter: grayscale(0.85) brightness(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
.topo-node__glyph {
|
.topo-node__glyph {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -2252,14 +2449,20 @@ pre {
|
||||||
transform: translate(-50%, -50%) !important;
|
transform: translate(-50%, -50%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-node.is-selected .topo-node__icon-body {
|
.topo-node.is-selected .topo-node__glyph {
|
||||||
stroke: #0f172a;
|
outline: 2px solid #2563eb;
|
||||||
stroke-width: 1.6;
|
outline-offset: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node.is-selected .topo-node__icon {
|
||||||
|
transform: scale(1.08);
|
||||||
|
filter: var(--topo-icon-filter, none) drop-shadow(0 0 8px rgba(37, 99, 235, 0.55));
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-node.is-selected .topo-node__caption {
|
.topo-node.is-selected .topo-node__caption {
|
||||||
color: #0f172a;
|
color: #1d4ed8;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-node__icon {
|
.topo-node__icon {
|
||||||
|
|
@ -2267,30 +2470,11 @@ pre {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
object-fit: contain;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
user-select: none;
|
||||||
|
filter: var(--topo-icon-filter, none);
|
||||||
.topo-node__icon-shadow {
|
transition: transform 0.12s ease, filter 0.12s ease;
|
||||||
fill: rgba(15, 23, 42, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-node__icon-rim {
|
|
||||||
fill: color-mix(in srgb, var(--topo-vendor, #64748b) 70%, #0f172a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-node__icon-body {
|
|
||||||
fill: var(--topo-vendor, #64748b);
|
|
||||||
stroke: color-mix(in srgb, var(--topo-vendor, #64748b) 45%, #0f172a);
|
|
||||||
stroke-width: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-node__icon-face {
|
|
||||||
fill: color-mix(in srgb, var(--topo-vendor, #64748b) 62%, #cbd5e1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topo-node__icon-arrows {
|
|
||||||
fill: #ffffff;
|
|
||||||
stroke: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Same plain-text style as edge port labels */
|
/* Same plain-text style as edge port labels */
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
|
ControlButton,
|
||||||
MiniMap,
|
MiniMap,
|
||||||
addEdge,
|
addEdge,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
|
|
@ -31,6 +32,7 @@ import {
|
||||||
updateTopologyMap,
|
updateTopologyMap,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
import { queryKeys } from "../constants/queryKeys";
|
import { queryKeys } from "../constants/queryKeys";
|
||||||
|
import { HelpHint } from "../components/HelpHint";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { useToast } from "../hooks/useToast";
|
import { useToast } from "../hooks/useToast";
|
||||||
import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows";
|
import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows";
|
||||||
|
|
@ -43,6 +45,27 @@ import type {
|
||||||
UmeNeItem,
|
UmeNeItem,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
|
function FullscreenIcon({ exit }: { exit?: boolean }) {
|
||||||
|
if (exit) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M7 14H5v5h5v-2H7v-3zm12 0h-2v3h-3v2h5v-5zM7 5h3V3H5v5h2V5zm10 0v3h2V3h-5v2h3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type NeNodeData = {
|
type NeNodeData = {
|
||||||
label: string;
|
label: string;
|
||||||
managed_ne_id: string;
|
managed_ne_id: string;
|
||||||
|
|
@ -72,6 +95,10 @@ type TopoDisplayOpts = {
|
||||||
hidePorts: boolean;
|
hidePorts: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CtxMenu =
|
||||||
|
| { kind: "node"; id: string; x: number; y: number }
|
||||||
|
| { kind: "edge"; id: string; x: number; y: number };
|
||||||
|
|
||||||
const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||||
hideIp: true,
|
hideIp: true,
|
||||||
hideVendor: true,
|
hideVendor: true,
|
||||||
|
|
@ -97,25 +124,16 @@ function vendorTone(vendor: string): string {
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Classic Visio/Packet Tracer style router: disc + four outbound arrows. */
|
/** Asset: /topo/ne-router.png (default blue = ZTE); other vendors tint via CSS. */
|
||||||
function RouterIcon() {
|
function RouterIcon() {
|
||||||
return (
|
return (
|
||||||
<svg className="topo-node__icon" viewBox="0 0 64 64" aria-hidden="true">
|
<img
|
||||||
<ellipse cx="32" cy="39" rx="19" ry="8.5" className="topo-node__icon-shadow" />
|
className="topo-node__icon"
|
||||||
<path
|
src="/topo/ne-router.png"
|
||||||
d="M13 31 A19 9.5 0 0 0 51 31 L51 36.5 A19 9.5 0 0 1 13 36.5 Z"
|
alt=""
|
||||||
className="topo-node__icon-rim"
|
draggable={false}
|
||||||
/>
|
aria-hidden="true"
|
||||||
<ellipse cx="32" cy="31" rx="19" ry="9.5" className="topo-node__icon-body" />
|
/>
|
||||||
<ellipse cx="32" cy="30" rx="15.5" ry="7" className="topo-node__icon-face" />
|
|
||||||
<g className="topo-node__icon-arrows">
|
|
||||||
{/* NW / NE / SW / SE */}
|
|
||||||
<path d="M31 29 L19 18 L24.5 18.8 L23.2 23.8 Z" />
|
|
||||||
<path d="M33 29 L45 18 L39.5 18.8 L40.8 23.8 Z" />
|
|
||||||
<path d="M31 31 L19 42 L24.5 41.2 L23.2 36.2 Z" />
|
|
||||||
<path d="M33 31 L45 42 L39.5 41.2 L40.8 36.2 Z" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,6 +241,7 @@ export function TopologyPage() {
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null);
|
||||||
const [hideIp, setHideIp] = useState(true);
|
const [hideIp, setHideIp] = useState(true);
|
||||||
const [hideVendor, setHideVendor] = useState(true);
|
const [hideVendor, setHideVendor] = useState(true);
|
||||||
const [hidePorts, setHidePorts] = useState(true);
|
const [hidePorts, setHidePorts] = useState(true);
|
||||||
|
|
@ -243,10 +262,12 @@ export function TopologyPage() {
|
||||||
edgesUpdated: 0,
|
edgesUpdated: 0,
|
||||||
});
|
});
|
||||||
const [discoverError, setDiscoverError] = useState("");
|
const [discoverError, setDiscoverError] = useState("");
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const discoverAbortRef = useRef<AbortController | null>(null);
|
const discoverAbortRef = useRef<AbortController | null>(null);
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dirtyRef = useRef(false);
|
const dirtyRef = useRef(false);
|
||||||
const displayOpts = useMemo(
|
const displayOpts = useMemo(
|
||||||
() => ({ hideIp, hideVendor, hidePorts }),
|
() => ({ hideIp, hideVendor, hidePorts }),
|
||||||
|
|
@ -369,84 +390,99 @@ export function TopologyPage() {
|
||||||
onError: (err) => showError(String(err)),
|
onError: (err) => showError(String(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const runDiscover = useCallback(async () => {
|
const runDiscover = useCallback(
|
||||||
if (!mapId || discovering) return;
|
async (neIds?: string[]) => {
|
||||||
discoverAbortRef.current?.abort();
|
if (!mapId || discovering) return;
|
||||||
const ac = new AbortController();
|
const filterIds = (neIds || []).map((x) => String(x || "").trim()).filter(Boolean);
|
||||||
discoverAbortRef.current = ac;
|
discoverAbortRef.current?.abort();
|
||||||
setDiscoverOpen(true);
|
const ac = new AbortController();
|
||||||
setDiscovering(true);
|
discoverAbortRef.current = ac;
|
||||||
setDiscoverReport(null);
|
setDiscoverOpen(true);
|
||||||
setDiscoverLiveResults([]);
|
setDiscovering(true);
|
||||||
setDiscoverError("");
|
setDiscoverReport(null);
|
||||||
setDiscoverProgress({
|
setDiscoverLiveResults([]);
|
||||||
index: 0,
|
setDiscoverError("");
|
||||||
total: nodes.filter((n) => Boolean(n.data.managed_ne_id)).length,
|
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
|
||||||
neName: "",
|
const scoped = filterIds.length
|
||||||
neIp: "",
|
? scannable.filter(
|
||||||
edgesAdded: 0,
|
(n) =>
|
||||||
edgesUpdated: 0,
|
filterIds.includes(String(n.data.managed_ne_id || "")) ||
|
||||||
});
|
filterIds.includes(String(n.data.ume_ne_id || "")),
|
||||||
try {
|
)
|
||||||
if (dirtyRef.current) {
|
: scannable;
|
||||||
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
|
setDiscoverProgress({
|
||||||
dirtyRef.current = false;
|
index: 0,
|
||||||
|
total: scoped.length,
|
||||||
|
neName: "",
|
||||||
|
neIp: "",
|
||||||
|
edgesAdded: 0,
|
||||||
|
edgesUpdated: 0,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (dirtyRef.current) {
|
||||||
|
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
|
||||||
|
dirtyRef.current = false;
|
||||||
|
}
|
||||||
|
const out = await discoverTopologyNeighborsStream(
|
||||||
|
mapId,
|
||||||
|
{
|
||||||
|
protocol: "auto",
|
||||||
|
...(filterIds.length ? { ne_ids: filterIds } : {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onStart: (ev) => {
|
||||||
|
setDiscoverProgress((p) => ({ ...p, total: ev.total, index: 0 }));
|
||||||
|
},
|
||||||
|
onNeStart: (ev) => {
|
||||||
|
setDiscoverProgress((p) => ({
|
||||||
|
...p,
|
||||||
|
index: ev.index,
|
||||||
|
total: ev.total,
|
||||||
|
neName: ev.ne_name || ev.ne_ip || ev.ne_id,
|
||||||
|
neIp: ev.ne_ip,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onNeResult: (ev) => {
|
||||||
|
if (ev.result) {
|
||||||
|
setDiscoverLiveResults((prev) => [...prev, ev.result]);
|
||||||
|
}
|
||||||
|
setDiscoverProgress((p) => ({
|
||||||
|
...p,
|
||||||
|
index: ev.index,
|
||||||
|
total: ev.total,
|
||||||
|
edgesAdded: ev.edges_added,
|
||||||
|
edgesUpdated: ev.edges_updated,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ac.signal,
|
||||||
|
);
|
||||||
|
if (out.graph) {
|
||||||
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
|
||||||
|
const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges);
|
||||||
|
setNodes(rfNodes);
|
||||||
|
setEdges(rfEdges);
|
||||||
|
dirtyRef.current = false;
|
||||||
|
}
|
||||||
|
setDiscoverReport(out);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
showOk(
|
||||||
|
t("topology.discovered")
|
||||||
|
.replace("{{added}}", String(out.edges_added))
|
||||||
|
.replace("{{updated}}", String(out.edges_updated))
|
||||||
|
.replace("{{stale}}", String(out.edges_stale || 0)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
setDiscoverError(String(err));
|
||||||
|
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
|
||||||
|
} finally {
|
||||||
|
if (discoverAbortRef.current === ac) discoverAbortRef.current = null;
|
||||||
|
setDiscovering(false);
|
||||||
}
|
}
|
||||||
const out = await discoverTopologyNeighborsStream(
|
},
|
||||||
mapId,
|
[mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t],
|
||||||
{ protocol: "auto" },
|
);
|
||||||
{
|
|
||||||
onStart: (ev) => {
|
|
||||||
setDiscoverProgress((p) => ({ ...p, total: ev.total, index: 0 }));
|
|
||||||
},
|
|
||||||
onNeStart: (ev) => {
|
|
||||||
setDiscoverProgress((p) => ({
|
|
||||||
...p,
|
|
||||||
index: ev.index,
|
|
||||||
total: ev.total,
|
|
||||||
neName: ev.ne_name || ev.ne_ip || ev.ne_id,
|
|
||||||
neIp: ev.ne_ip,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
onNeResult: (ev) => {
|
|
||||||
if (ev.result) {
|
|
||||||
setDiscoverLiveResults((prev) => [...prev, ev.result]);
|
|
||||||
}
|
|
||||||
setDiscoverProgress((p) => ({
|
|
||||||
...p,
|
|
||||||
index: ev.index,
|
|
||||||
total: ev.total,
|
|
||||||
edgesAdded: ev.edges_added,
|
|
||||||
edgesUpdated: ev.edges_updated,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ac.signal,
|
|
||||||
);
|
|
||||||
if (out.graph) {
|
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
|
|
||||||
const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges);
|
|
||||||
setNodes(rfNodes);
|
|
||||||
setEdges(rfEdges);
|
|
||||||
dirtyRef.current = false;
|
|
||||||
}
|
|
||||||
setDiscoverReport(out);
|
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
|
||||||
showOk(
|
|
||||||
t("topology.discovered")
|
|
||||||
.replace("{{added}}", String(out.edges_added))
|
|
||||||
.replace("{{updated}}", String(out.edges_updated))
|
|
||||||
.replace("{{stale}}", String(out.edges_stale || 0)),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (ac.signal.aborted) return;
|
|
||||||
setDiscoverError(String(err));
|
|
||||||
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
|
|
||||||
} finally {
|
|
||||||
if (discoverAbortRef.current === ac) discoverAbortRef.current = null;
|
|
||||||
setDiscovering(false);
|
|
||||||
}
|
|
||||||
}, [mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t]);
|
|
||||||
|
|
||||||
const discoverResults = discoverReport?.results?.length
|
const discoverResults = discoverReport?.results?.length
|
||||||
? discoverReport.results
|
? discoverReport.results
|
||||||
|
|
@ -558,10 +594,6 @@ export function TopologyPage() {
|
||||||
() => nodes.find((n) => n.id === selectedNodeId) || null,
|
() => nodes.find((n) => n.id === selectedNodeId) || null,
|
||||||
[nodes, selectedNodeId],
|
[nodes, selectedNodeId],
|
||||||
);
|
);
|
||||||
const selectedEdge = useMemo(
|
|
||||||
() => edges.find((e) => e.id === selectedEdgeId) || null,
|
|
||||||
[edges, selectedEdgeId],
|
|
||||||
);
|
|
||||||
const staleEdgeCount = useMemo(
|
const staleEdgeCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale")
|
edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale")
|
||||||
|
|
@ -569,18 +601,62 @@ export function TopologyPage() {
|
||||||
[edges],
|
[edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeSelected = () => {
|
const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
|
||||||
if (selectedEdgeId) {
|
|
||||||
dirtyRef.current = true;
|
useEffect(() => {
|
||||||
setEdges((es) => es.filter((e) => e.id !== selectedEdgeId));
|
const syncFs = () => {
|
||||||
setSelectedEdgeId(null);
|
const el = canvasRef.current;
|
||||||
return;
|
const active = Boolean(el && document.fullscreenElement === el);
|
||||||
|
setFullscreen(active);
|
||||||
|
if (active) {
|
||||||
|
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.15 }), 80);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("fullscreenchange", syncFs);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", syncFs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(async () => {
|
||||||
|
const el = canvasRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement === el) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
await el.requestFullscreen();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError(String(err));
|
||||||
}
|
}
|
||||||
if (!selectedNodeId) return;
|
}, [showError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ctxMenu) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") closeCtxMenu();
|
||||||
|
};
|
||||||
|
const onScroll = () => closeCtxMenu();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
window.addEventListener("scroll", onScroll, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
};
|
||||||
|
}, [ctxMenu, closeCtxMenu]);
|
||||||
|
|
||||||
|
const removeNodeById = (nodeId: string) => {
|
||||||
dirtyRef.current = true;
|
dirtyRef.current = true;
|
||||||
setNodes((ns) => ns.filter((n) => n.id !== selectedNodeId));
|
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
|
||||||
setEdges((es) => es.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId));
|
setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId((cur) => (cur === nodeId ? null : cur));
|
||||||
|
closeCtxMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEdgeById = (edgeId: string) => {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setEdges((es) => es.filter((e) => e.id !== edgeId));
|
||||||
|
setSelectedEdgeId((cur) => (cur === edgeId ? null : cur));
|
||||||
|
closeCtxMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeStaleEdges = () => {
|
const removeStaleEdges = () => {
|
||||||
|
|
@ -594,9 +670,10 @@ export function TopologyPage() {
|
||||||
showOk(t("topology.staleRemoved").replace("{{count}}", String(n)));
|
showOk(t("topology.staleRemoved").replace("{{count}}", String(n)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openWebcrt = () => {
|
const openWebcrtFor = (node: Node<NeNodeData> | null) => {
|
||||||
const managedId = selectedNode?.data.managed_ne_id;
|
closeCtxMenu();
|
||||||
const umeId = selectedNode?.data.ume_ne_id;
|
const managedId = node?.data.managed_ne_id;
|
||||||
|
const umeId = node?.data.ume_ne_id;
|
||||||
if (managedId) {
|
if (managedId) {
|
||||||
openNewModuleWindow({
|
openNewModuleWindow({
|
||||||
moduleId: "webcrt",
|
moduleId: "webcrt",
|
||||||
|
|
@ -614,9 +691,10 @@ export function TopologyPage() {
|
||||||
showError(t("topology.noNeLink"));
|
showError(t("topology.noNeLink"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openNe = () => {
|
const openNeFor = (node: Node<NeNodeData> | null) => {
|
||||||
const managedId = selectedNode?.data.managed_ne_id;
|
closeCtxMenu();
|
||||||
const umeId = selectedNode?.data.ume_ne_id;
|
const managedId = node?.data.managed_ne_id;
|
||||||
|
const umeId = node?.data.ume_ne_id;
|
||||||
if (managedId) {
|
if (managedId) {
|
||||||
openOrFocusModule({
|
openOrFocusModule({
|
||||||
moduleId: "managed-ne",
|
moduleId: "managed-ne",
|
||||||
|
|
@ -634,6 +712,52 @@ export function TopologyPage() {
|
||||||
showError(t("topology.noNeLink"));
|
showError(t("topology.noNeLink"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const discoverOneFor = (node: Node<NeNodeData> | null) => {
|
||||||
|
closeCtxMenu();
|
||||||
|
const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim();
|
||||||
|
if (!id) {
|
||||||
|
showError(t("topology.discoverOneNeedNe"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runDiscover([id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeCtxMenu = (clientX: number, clientY: number): { x: number; y: number } => {
|
||||||
|
const pad = 8;
|
||||||
|
const w = 180;
|
||||||
|
const h = 200;
|
||||||
|
const x = Math.min(clientX, window.innerWidth - w - pad);
|
||||||
|
const y = Math.min(clientY, window.innerHeight - h - pad);
|
||||||
|
return { x: Math.max(pad, x), y: Math.max(pad, y) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectNodeOnly = useCallback(
|
||||||
|
(nodeId: string) => {
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
setSelectedNodeId(nodeId);
|
||||||
|
setNodes((ns) => ns.map((n) => ({ ...n, selected: n.id === nodeId })));
|
||||||
|
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||||
|
},
|
||||||
|
[setNodes, setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectEdgeOnly = useCallback(
|
||||||
|
(edgeId: string) => {
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(edgeId);
|
||||||
|
setNodes((ns) => ns.map((n) => ({ ...n, selected: false })));
|
||||||
|
setEdges((es) => es.map((e) => ({ ...e, selected: e.id === edgeId })));
|
||||||
|
},
|
||||||
|
[setNodes, setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
setNodes((ns) => ns.map((n) => ({ ...n, selected: false })));
|
||||||
|
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||||
|
}, [setNodes, setEdges]);
|
||||||
|
|
||||||
const maps = mapsQuery.data?.items || [];
|
const maps = mapsQuery.data?.items || [];
|
||||||
const onCanvasManagedIds = useMemo(
|
const onCanvasManagedIds = useMemo(
|
||||||
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
|
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
|
||||||
|
|
@ -731,38 +855,42 @@ export function TopologyPage() {
|
||||||
<ul className="topo-map-list">
|
<ul className="topo-map-list">
|
||||||
{maps.map((m) => (
|
{maps.map((m) => (
|
||||||
<li key={m.id} className={mapId === m.id ? "is-active" : ""}>
|
<li key={m.id} className={mapId === m.id ? "is-active" : ""}>
|
||||||
<button
|
<div className="topo-map-list__row">
|
||||||
type="button"
|
<button
|
||||||
className="topo-map-list__item"
|
type="button"
|
||||||
onClick={() => setMapId(m.id)}
|
className="topo-map-list__item"
|
||||||
onDoubleClick={() => promptRenameMap(m.id, m.name)}
|
onClick={() => setMapId(m.id)}
|
||||||
title={t("topology.renameHint")}
|
onDoubleClick={() => promptRenameMap(m.id, m.name)}
|
||||||
>
|
title={t("topology.renameHint")}
|
||||||
<span className="topo-map-list__name">{m.name}</span>
|
>
|
||||||
<span className="topo-map-list__meta">
|
<span className="topo-map-list__name">{m.name}</span>
|
||||||
{m.node_count}N / {m.edge_count}E
|
<span className="topo-map-list__meta">
|
||||||
</span>
|
{m.node_count}N / {m.edge_count}E
|
||||||
</button>
|
</span>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<div className="topo-map-list__actions">
|
||||||
className="btn btn--sm btn--ghost"
|
<button
|
||||||
title={t("topology.rename")}
|
type="button"
|
||||||
disabled={renameMapMut.isPending}
|
className="topo-map-list__icon"
|
||||||
onClick={() => promptRenameMap(m.id, m.name)}
|
title={t("topology.rename")}
|
||||||
>
|
disabled={renameMapMut.isPending}
|
||||||
✎
|
onClick={() => promptRenameMap(m.id, m.name)}
|
||||||
</button>
|
>
|
||||||
<button
|
✎
|
||||||
type="button"
|
</button>
|
||||||
className="btn btn--sm btn--ghost"
|
<button
|
||||||
title={t("topology.deleteMap")}
|
type="button"
|
||||||
onClick={() => {
|
className="topo-map-list__icon"
|
||||||
const msg = t("topology.deleteMapConfirm").replace("{{name}}", m.name);
|
title={t("topology.deleteMap")}
|
||||||
if (window.confirm(msg)) deleteMapMut.mutate(m.id);
|
onClick={() => {
|
||||||
}}
|
const msg = t("topology.deleteMapConfirm").replace("{{name}}", m.name);
|
||||||
>
|
if (window.confirm(msg)) deleteMapMut.mutate(m.id);
|
||||||
×
|
}}
|
||||||
</button>
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -828,20 +956,22 @@ export function TopologyPage() {
|
||||||
? onCanvasManagedIds.has(item.managed_ne_id)
|
? onCanvasManagedIds.has(item.managed_ne_id)
|
||||||
: onCanvasUmeIds.has(item.ume_ne_id);
|
: onCanvasUmeIds.has(item.ume_ne_id);
|
||||||
return (
|
return (
|
||||||
<li key={item.key}>
|
<li key={item.key} className={onCanvas ? "is-on-canvas" : ""}>
|
||||||
<button
|
<div className="topo-palette__row">
|
||||||
type="button"
|
<button
|
||||||
className="topo-palette__item"
|
type="button"
|
||||||
disabled={!mapId || onCanvas}
|
className="topo-palette__item"
|
||||||
onClick={() => addPaletteItem(item)}
|
disabled={!mapId || onCanvas}
|
||||||
title={`${item.name}\n${item.ip}`}
|
onClick={() => addPaletteItem(item)}
|
||||||
>
|
title={`${item.name}\n${item.ip}`}
|
||||||
<span className="topo-palette__name">{item.name}</span>
|
>
|
||||||
<span className="topo-palette__meta">
|
<span className="topo-palette__name">{item.name}</span>
|
||||||
{item.meta}
|
<span className="topo-palette__meta">
|
||||||
{onCanvas ? " ✓" : ""}
|
{item.meta}
|
||||||
</span>
|
{onCanvas ? " ✓" : ""}
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
@ -857,22 +987,8 @@ export function TopologyPage() {
|
||||||
<div className="topo-toolbar__left">
|
<div className="topo-toolbar__left">
|
||||||
<div className="topo-toolbar__title">
|
<div className="topo-toolbar__title">
|
||||||
<strong>{maps.find((m) => m.id === mapId)?.name || t("topology.selectMap")}</strong>
|
<strong>{maps.find((m) => m.id === mapId)?.name || t("topology.selectMap")}</strong>
|
||||||
{mapId ? (
|
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn--sm btn--ghost"
|
|
||||||
title={t("topology.rename")}
|
|
||||||
disabled={renameMapMut.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
const current = maps.find((m) => m.id === mapId)?.name || "";
|
|
||||||
promptRenameMap(mapId, current);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("topology.rename")}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="panel__hint">{t("topology.canvasHint")}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="topo-toolbar__actions">
|
<div className="topo-toolbar__actions">
|
||||||
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
|
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
|
||||||
|
|
@ -945,41 +1061,6 @@ export function TopologyPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedNode ? (
|
|
||||||
<div className="topo-selection">
|
|
||||||
<span>
|
|
||||||
{t("topology.selected")}: <strong>{selectedNode.data.label}</strong>
|
|
||||||
{selectedNode.data.ne_ip ? ` (${selectedNode.data.ne_ip})` : ""}
|
|
||||||
</span>
|
|
||||||
<div className="topo-selection__actions">
|
|
||||||
<button type="button" className="btn btn--sm" onClick={openWebcrt}>
|
|
||||||
{t("topology.openWebcrt")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn--sm" onClick={openNe}>
|
|
||||||
{t("topology.openNe")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn--sm btn--ghost" onClick={removeSelected}>
|
|
||||||
{t("topology.removeNode")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : selectedEdge ? (
|
|
||||||
<div className="topo-selection">
|
|
||||||
<span>
|
|
||||||
{t("topology.selectedEdge")}:{" "}
|
|
||||||
<strong>
|
|
||||||
{String((selectedEdge.data as { source?: string } | undefined)?.source || "manual")}
|
|
||||||
</strong>
|
|
||||||
{selectedEdge.label ? ` · ${String(selectedEdge.label)}` : ""}
|
|
||||||
</span>
|
|
||||||
<div className="topo-selection__actions">
|
|
||||||
<button type="button" className="btn btn--sm btn--ghost" onClick={removeSelected}>
|
|
||||||
{t("topology.removeEdge")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{discoverOpen ? (
|
{discoverOpen ? (
|
||||||
<div className="topo-discover">
|
<div className="topo-discover">
|
||||||
<div className="topo-discover__head">
|
<div className="topo-discover__head">
|
||||||
|
|
@ -1047,7 +1128,7 @@ export function TopologyPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="topo-canvas">
|
<div className={`topo-canvas${fullscreen ? " is-fullscreen" : ""}`} ref={canvasRef}>
|
||||||
{mapId ? (
|
{mapId ? (
|
||||||
<TopoDisplayContext.Provider value={displayOpts}>
|
<TopoDisplayContext.Provider value={displayOpts}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
|
@ -1057,26 +1138,39 @@ export function TopologyPage() {
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
defaultEdgeOptions={{ type: "straight" }}
|
defaultEdgeOptions={{ type: "straight" }}
|
||||||
onNodesChange={(changes) => {
|
onNodesChange={(changes) => {
|
||||||
dirtyRef.current = true;
|
if (changes.some((c) => c.type !== "select")) dirtyRef.current = true;
|
||||||
onNodesChange(changes);
|
onNodesChange(changes);
|
||||||
}}
|
}}
|
||||||
onEdgesChange={(changes) => {
|
onEdgesChange={(changes) => {
|
||||||
dirtyRef.current = true;
|
if (changes.some((c) => c.type !== "select")) dirtyRef.current = true;
|
||||||
onEdgesChange(changes);
|
onEdgesChange(changes);
|
||||||
}}
|
}}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onNodeClick={(_e, node) => {
|
onNodeClick={(_e, node) => {
|
||||||
setSelectedEdgeId(null);
|
setCtxMenu(null);
|
||||||
setSelectedNodeId(node.id);
|
selectNodeOnly(node.id);
|
||||||
}}
|
}}
|
||||||
onEdgeClick={(_e, edge) => {
|
onEdgeClick={(_e, edge) => {
|
||||||
setSelectedNodeId(null);
|
setCtxMenu(null);
|
||||||
setSelectedEdgeId(edge.id);
|
selectEdgeOnly(edge.id);
|
||||||
}}
|
}}
|
||||||
onPaneClick={() => {
|
onPaneClick={() => {
|
||||||
setSelectedNodeId(null);
|
setCtxMenu(null);
|
||||||
setSelectedEdgeId(null);
|
clearSelection();
|
||||||
}}
|
}}
|
||||||
|
onNodeContextMenu={(e, node) => {
|
||||||
|
e.preventDefault();
|
||||||
|
selectNodeOnly(node.id);
|
||||||
|
const pos = placeCtxMenu(e.clientX, e.clientY);
|
||||||
|
setCtxMenu({ kind: "node", id: node.id, ...pos });
|
||||||
|
}}
|
||||||
|
onEdgeContextMenu={(e, edge) => {
|
||||||
|
e.preventDefault();
|
||||||
|
selectEdgeOnly(edge.id);
|
||||||
|
const pos = placeCtxMenu(e.clientX, e.clientY);
|
||||||
|
setCtxMenu({ kind: "edge", id: edge.id, ...pos });
|
||||||
|
}}
|
||||||
|
onMoveStart={closeCtxMenu}
|
||||||
onInit={(inst) => {
|
onInit={(inst) => {
|
||||||
rfRef.current = inst;
|
rfRef.current = inst;
|
||||||
}}
|
}}
|
||||||
|
|
@ -1086,7 +1180,16 @@ export function TopologyPage() {
|
||||||
elementsSelectable
|
elementsSelectable
|
||||||
>
|
>
|
||||||
<Background gap={18} size={1} />
|
<Background gap={18} size={1} />
|
||||||
<Controls />
|
<Controls showInteractive>
|
||||||
|
<ControlButton
|
||||||
|
className="topo-fs-control"
|
||||||
|
onClick={() => void toggleFullscreen()}
|
||||||
|
title={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
|
||||||
|
aria-label={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
|
||||||
|
>
|
||||||
|
<FullscreenIcon exit={fullscreen} />
|
||||||
|
</ControlButton>
|
||||||
|
</Controls>
|
||||||
<MiniMap pannable zoomable />
|
<MiniMap pannable zoomable />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</TopoDisplayContext.Provider>
|
</TopoDisplayContext.Provider>
|
||||||
|
|
@ -1095,6 +1198,73 @@ export function TopologyPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{ctxMenu ? (
|
||||||
|
<ul
|
||||||
|
className="topo-ctx"
|
||||||
|
style={{ left: ctxMenu.x, top: ctxMenu.y }}
|
||||||
|
role="menu"
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{ctxMenu.kind === "node" ? (
|
||||||
|
<>
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-ctx__item"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={discovering}
|
||||||
|
onClick={() => discoverOneFor(selectedNode)}
|
||||||
|
>
|
||||||
|
{t("topology.discoverOne")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-ctx__item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => openWebcrtFor(selectedNode)}
|
||||||
|
>
|
||||||
|
{t("topology.openWebcrt")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-ctx__item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => openNeFor(selectedNode)}
|
||||||
|
>
|
||||||
|
{t("topology.openNe")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className="topo-ctx__sep" aria-hidden />
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-ctx__item topo-ctx__item--danger"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => removeNodeById(ctxMenu.id)}
|
||||||
|
>
|
||||||
|
{t("topology.removeNode")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-ctx__item topo-ctx__item--danger"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => removeEdgeById(ctxMenu.id)}
|
||||||
|
>
|
||||||
|
{t("topology.removeEdge")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue