mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Improve topology discovery UX with review modals and live search.
Report unmatched/stub neighbors and matched links, move result lists into detail dialogs, deep-link edges to port traffic, and add fuzzy type-ahead canvas/palette NE search. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2382cb8269
commit
8cf1696d8c
12 changed files with 1442 additions and 66 deletions
|
|
@ -116,6 +116,7 @@ src/
|
|||
- 单飞:同一任务同时只允许一轮采集;崩溃启动清除 `collect_running`
|
||||
- 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
|
||||
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口)
|
||||
- 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元
|
||||
|
||||
## 拓扑管理
|
||||
|
||||
|
|
@ -126,6 +127,9 @@ src/
|
|||
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
|
||||
- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名
|
||||
- 未保存切换地图会确认;Ctrl+S 保存;发现可取消;禁止自环与重复连线;PUT 保留 `discovered_at`;发现边键对端口名做规范化
|
||||
- 发现进度主区只显示摘要与入口;点「详情」打开列表弹窗,再点设备打开链路复核弹窗
|
||||
- 工具栏可搜索定位并高亮;侧栏已上图网元点击可定位
|
||||
- 链路右键可跳转端口流量(`/network/tasks/port-traffic?ne_id=&source=&ifname=`)
|
||||
|
||||
## WebCRT
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ const en = {
|
|||
concurrency: "Concurrency",
|
||||
startNow: "Start after create",
|
||||
neKeywordPh: "Name / IP / vendor",
|
||||
deepLinkHint: "Opened from topology: NE {{ne}}{{ifname}}. Confirm selection, then continue.",
|
||||
deepLinkIfname: ", interface {{ifname}}",
|
||||
selectedNe: "{{count}} NE(s) selected",
|
||||
selectedPorts: "{{count}} interface(s) selected",
|
||||
pickNeFirst: "Select NEs in the previous step first",
|
||||
|
|
@ -889,7 +891,7 @@ const en = {
|
|||
emptyMaps: "No maps yet. Create one to start.",
|
||||
palette: "NE library",
|
||||
paletteHint: "Click or drag onto the canvas",
|
||||
filterPh: "Filter name / IP",
|
||||
filterPh: "Type to filter: name / IP / vendor",
|
||||
save: "Save",
|
||||
saveDirty: "Save *",
|
||||
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
|
||||
|
|
@ -985,8 +987,45 @@ const en = {
|
|||
discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}",
|
||||
discoverNeOk: "neighbors {{neighbors}} · added {{added}} · updated {{updated}}",
|
||||
discoverNeFail: "Collect failed",
|
||||
discoverUnmatched: "unmatched {{count}}",
|
||||
discoverUnmatchedMore: "{{count}} more not listed",
|
||||
discoverParserStub: "Parser \"{{parser}}\" is still a stub; output was collected but neighbors were not parsed",
|
||||
discoverNoTargets:
|
||||
"No scannable NEs on the map. Add managed NEs, or UME NEs with a CLI connect profile.",
|
||||
discoverFilter: "Result filter",
|
||||
discoverFilterIssues: "Issues",
|
||||
discoverFilterFail: "Failed",
|
||||
discoverFilterWarn: "Warnings",
|
||||
discoverFilterOk: "OK",
|
||||
discoverFilterAll: "All",
|
||||
discoverFilterDetail: "Details",
|
||||
discoverListTitle: "Discovery details",
|
||||
discoverListEmpty: "No NEs under the current filter.",
|
||||
discoverShowLimited: "Showing first {{shown}} / {{total}} (narrow with filters)",
|
||||
discoverViewDetail: "Detail",
|
||||
discoverLinkCount: "{{count}} link(s)",
|
||||
discoverLinksTitle: "Matched links ({{count}})",
|
||||
discoverLinksEmpty: "No on-map peer links matched in this scan.",
|
||||
discoverUnmatchedTitle: "Unmatched neighbors ({{count}})",
|
||||
discoverUnmatchedEmpty: "No unmatched neighbors.",
|
||||
discoverColPeer: "Peer NE",
|
||||
discoverColRemote: "Neighbor",
|
||||
discoverColLocalPort: "Local port",
|
||||
discoverColRemotePort: "Remote port",
|
||||
discoverColProtocol: "Protocol",
|
||||
discoverColAction: "Result",
|
||||
discoverLinkAdded: "Added",
|
||||
discoverLinkUpdated: "Updated",
|
||||
discoverLinkKeptManual: "Kept manual",
|
||||
discoverLocatePeer: "Locate peer",
|
||||
discoverLocateNe: "Locate NE",
|
||||
discoverRawPreview: "Raw output preview (truncated when long; parsing uses full output)",
|
||||
findNode: "Find",
|
||||
findNodePh: "Type to search: name / IP / vendor",
|
||||
findNoMatch: "No matching node on the canvas",
|
||||
findMore: "{{count}} more matches…",
|
||||
locateOnCanvas: "Click to locate on canvas",
|
||||
openPortTraffic: "Open port traffic",
|
||||
removeStale: "Clear missing ({{count}})",
|
||||
removeStaleHint: "Delete red-marked missing edges",
|
||||
staleRemoved: "Removed {{count}} missing edge(s)",
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ const zh = {
|
|||
concurrency: "并发",
|
||||
startNow: "创建后立即启动",
|
||||
neKeywordPh: "名称 / IP / 厂商",
|
||||
deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续创建监控任务。",
|
||||
deepLinkIfname: ",接口 {{ifname}}",
|
||||
selectedNe: "已选 {{count}} 台网元",
|
||||
selectedPorts: "已选 {{count}} 个接口",
|
||||
pickNeFirst: "请先在上一步选择网元",
|
||||
|
|
@ -886,7 +888,7 @@ const zh = {
|
|||
emptyMaps: "还没有拓扑图,先新建一张。",
|
||||
palette: "网元库",
|
||||
paletteHint: "点击或拖到画布添加",
|
||||
filterPh: "筛选名称 / IP",
|
||||
filterPh: "输入即筛:名称 / IP / 厂商",
|
||||
save: "保存",
|
||||
saveDirty: "保存 *",
|
||||
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
||||
|
|
@ -982,7 +984,44 @@ const zh = {
|
|||
discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}",
|
||||
discoverNeOk: "邻居 {{neighbors}} · 新增 {{added}} · 更新 {{updated}}",
|
||||
discoverNeFail: "采集失败",
|
||||
discoverUnmatched: "未匹配 {{count}}",
|
||||
discoverUnmatchedMore: "另有 {{count}} 条未列出",
|
||||
discoverParserStub: "厂商解析器「{{parser}}」仍为 stub,已采集到输出但未能解析邻居",
|
||||
discoverNoTargets: "画布上没有可扫描网元。请添加托管网元,或添加已配置 CLI 凭据的 UME 网元。",
|
||||
discoverFilter: "结果筛选",
|
||||
discoverFilterIssues: "问题",
|
||||
discoverFilterFail: "失败",
|
||||
discoverFilterWarn: "警告",
|
||||
discoverFilterOk: "成功",
|
||||
discoverFilterAll: "全部",
|
||||
discoverFilterDetail: "详情",
|
||||
discoverListTitle: "发现详情",
|
||||
discoverListEmpty: "当前筛选下没有网元。",
|
||||
discoverShowLimited: "仅显示前 {{shown}} / {{total}} 条(请用筛选缩小范围)",
|
||||
discoverViewDetail: "详情",
|
||||
discoverLinkCount: "链路 {{count}}",
|
||||
discoverLinksTitle: "匹配链路({{count}})",
|
||||
discoverLinksEmpty: "本次未匹配到画布上的对端网元链路。",
|
||||
discoverUnmatchedTitle: "未匹配邻居({{count}})",
|
||||
discoverUnmatchedEmpty: "无未匹配邻居。",
|
||||
discoverColPeer: "对端网元",
|
||||
discoverColRemote: "邻居",
|
||||
discoverColLocalPort: "本端端口",
|
||||
discoverColRemotePort: "对端端口",
|
||||
discoverColProtocol: "协议",
|
||||
discoverColAction: "结果",
|
||||
discoverLinkAdded: "新增",
|
||||
discoverLinkUpdated: "更新",
|
||||
discoverLinkKeptManual: "保留人工",
|
||||
discoverLocatePeer: "定位对端",
|
||||
discoverLocateNe: "定位本端",
|
||||
discoverRawPreview: "采集原文预览(过长会截断,不影响解析)",
|
||||
findNode: "定位",
|
||||
findNodePh: "输入即搜:名称 / IP / 厂商",
|
||||
findNoMatch: "画布上没有匹配的节点",
|
||||
findMore: "另有 {{count}} 条匹配…",
|
||||
locateOnCanvas: "点击定位到画布",
|
||||
openPortTraffic: "打开端口流量",
|
||||
removeStale: "清除未发现 ({{count}})",
|
||||
removeStaleHint: "删除红色标记的未发现链路",
|
||||
staleRemoved: "已删除 {{count}} 条未发现链路",
|
||||
|
|
|
|||
|
|
@ -2265,6 +2265,79 @@ pre {
|
|||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.topo-toolbar__group--find {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topo-find-suggest {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
width: min(320px, 70vw);
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.topo-find-suggest__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topo-find-suggest__item:hover,
|
||||
.topo-find-suggest__item.is-active {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.topo-find-suggest__name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.topo-find-suggest__meta {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.topo-find-suggest__empty,
|
||||
.topo-find-suggest__more {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.topo-toolbar__find {
|
||||
width: 150px;
|
||||
max-width: 28vw;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topo-toolbar__find:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.topo-toolbar__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -2653,7 +2726,7 @@ pre {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 220px;
|
||||
max-height: none;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
|
@ -2667,12 +2740,106 @@ pre {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.topo-discover__progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.topo-discover__bar {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topo-discover__bar-fill {
|
||||
height: 100%;
|
||||
background: #2563eb;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.topo-discover__progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.topo-discover__summary {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.topo-discover__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.topo-discover__filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topo-discover__filter.is-active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.topo-discover__filter--primary {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.topo-discover__filter--primary .topo-discover__filter-count {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.topo-discover__filter-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topo-discover__empty-ok {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #166534;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topo-discover__link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.topo-discover__cap {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.topo-discover__error {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
|
|
@ -2688,49 +2855,273 @@ pre {
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
.topo-discover__list li {
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
background: #fff;
|
||||
.topo-discover__list--modal {
|
||||
max-height: min(52vh, 480px);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.topo-discover__list li.is-fail {
|
||||
.topo-discover-list-modal {
|
||||
width: min(880px, 100%);
|
||||
}
|
||||
|
||||
.topo-discover__list > li {
|
||||
margin-bottom: 2px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.topo-discover__list > li.is-fail {
|
||||
border-color: #fecaca;
|
||||
background: #fff1f2;
|
||||
}
|
||||
|
||||
.topo-discover__item-title {
|
||||
.topo-discover__list > li.is-warn {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.topo-discover__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topo-discover__row-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topo-discover__row-name {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.topo-discover__row-ip {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.topo-discover__row-meta {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.topo-discover__badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.topo-discover__list li.is-fail .topo-discover__badge {
|
||||
.topo-discover__list > li.is-fail .topo-discover__badge {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.topo-discover__list > li.is-warn .topo-discover__badge {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.topo-discover__detail {
|
||||
padding: 0 8px 6px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.topo-discover__item-meta,
|
||||
.topo-discover__item-cmd {
|
||||
.topo-discover__item-cmd,
|
||||
.topo-discover__item-warn {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.topo-discover__item-cmd {
|
||||
.topo-discover__item-warn {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.topo-discover__unmatched {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 11px;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.topo-discover__unmatched-more {
|
||||
list-style: none;
|
||||
margin-left: -1.1rem;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.topo-discover__row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topo-discover__detail-btn {
|
||||
font-size: 11px;
|
||||
color: #2563eb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topo-discover__badge--lg {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.topo-discover__badge--lg.is-fail {
|
||||
background: #fff1f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.topo-discover__badge--lg.is-warn {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.topo-discover-modal {
|
||||
display: block;
|
||||
width: min(960px, 100%);
|
||||
max-height: min(88vh, 900px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.topo-discover-modal__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topo-discover-modal__head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topo-discover-modal__sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.topo-discover-modal__stats {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.topo-discover-modal__section {
|
||||
margin: 14px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.topo-discover-modal__table-wrap {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
max-height: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topo-discover-modal__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.topo-discover-modal__table th,
|
||||
.topo-discover-modal__table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topo-discover-modal__table th {
|
||||
position: static;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topo-discover-modal__table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.topo-discover-modal__peer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.topo-discover-modal__peer span {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.topo-discover-action.is-added {
|
||||
color: #166534;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topo-discover-action.is-updated {
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topo-discover-action.is-kept_manual {
|
||||
color: #a16207;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topo-discover-modal__raw {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
max-height: min(36vh, 360px);
|
||||
overflow: auto;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.topo-discover-list-modal .topo-discover__list--modal {
|
||||
max-height: min(52vh, 480px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.topo-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
|
@ -2812,6 +3203,22 @@ pre {
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.react-flow__node.is-search-hit .topo-node__glyph {
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.85);
|
||||
border-radius: 10px;
|
||||
animation: topo-search-pulse 0.85s ease 2;
|
||||
}
|
||||
|
||||
@keyframes topo-search-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.85);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.topo-node__handle {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
|
|
|
|||
|
|
@ -137,6 +137,45 @@ function vendorTone(vendor: string): string {
|
|||
return "other";
|
||||
}
|
||||
|
||||
function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" {
|
||||
if (!r.ok) return "fail";
|
||||
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
|
||||
if (r.parser_stub || unmatchedCount > 0) return "warn";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function normalizeSearchText(s: string): string {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/[\s_\-./]+/g, "");
|
||||
}
|
||||
|
||||
/** Case-insensitive substring + loose subsequence (e.g. r1 ? router-1). */
|
||||
function fuzzyIncludes(haystack: string, needle: string): boolean {
|
||||
const h = normalizeSearchText(haystack);
|
||||
const n = normalizeSearchText(needle);
|
||||
if (!n) return true;
|
||||
if (!h) return false;
|
||||
if (h.includes(n)) return true;
|
||||
let i = 0;
|
||||
for (const ch of h) {
|
||||
if (ch === n[i]) i += 1;
|
||||
if (i >= n.length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
|
||||
const tokens = String(query || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (!tokens.length) return false;
|
||||
const bits = [n.data.label, n.data.ne_ip, n.data.vendor, n.data.managed_ne_id, n.data.ume_ne_id];
|
||||
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
|
||||
}
|
||||
|
||||
/** Asset: /topo/ne-router.png (default blue = ZTE); other vendors tint via CSS. */
|
||||
function RouterIcon() {
|
||||
return (
|
||||
|
|
@ -410,7 +449,18 @@ export function TopologyPage() {
|
|||
});
|
||||
const [discoverError, setDiscoverError] = useState("");
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [canvasQuery, setCanvasQuery] = useState("");
|
||||
const [searchHitIds, setSearchHitIds] = useState<string[]>([]);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [findActiveIdx, setFindActiveIdx] = useState(0);
|
||||
const [discoverFilter, setDiscoverFilter] = useState<"issues" | "fail" | "warn" | "ok" | "all">(
|
||||
"issues",
|
||||
);
|
||||
const [discoverDetail, setDiscoverDetail] = useState<TopologyDiscoverNeResult | null>(null);
|
||||
const [discoverListOpen, setDiscoverListOpen] = useState(false);
|
||||
const discoverAbortRef = useRef<AbortController | null>(null);
|
||||
const searchHitTimerRef = useRef<number | null>(null);
|
||||
const findBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
||||
|
|
@ -452,7 +502,7 @@ export function TopologyPage() {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300);
|
||||
const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
|
||||
|
|
@ -675,7 +725,7 @@ export function TopologyPage() {
|
|||
clearDirty();
|
||||
setMapId(row.id);
|
||||
showOk(t("topology.newMap"));
|
||||
// Prompt rename right after create — default name is a placeholder.
|
||||
// Prompt rename right after create ? default name is a placeholder.
|
||||
window.setTimeout(() => promptRenameMap(row.id, row.name), 0);
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
|
|
@ -718,6 +768,9 @@ export function TopologyPage() {
|
|||
setDiscoverReport(null);
|
||||
setDiscoverLiveResults([]);
|
||||
setDiscoverError("");
|
||||
setDiscoverFilter("issues");
|
||||
setDiscoverDetail(null);
|
||||
setDiscoverListOpen(false);
|
||||
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
|
||||
const scoped = filterIds.length
|
||||
? scannable.filter(
|
||||
|
|
@ -817,21 +870,49 @@ export function TopologyPage() {
|
|||
const discoverResults = discoverReport?.results?.length
|
||||
? discoverReport.results
|
||||
: discoverLiveResults;
|
||||
const discoverCounts = useMemo(() => {
|
||||
let ok = 0;
|
||||
let warn = 0;
|
||||
let fail = 0;
|
||||
for (const r of discoverResults) {
|
||||
const k = discoverResultKind(r);
|
||||
if (k === "fail") fail += 1;
|
||||
else if (k === "warn") warn += 1;
|
||||
else ok += 1;
|
||||
}
|
||||
return { ok, warn, fail, issues: warn + fail };
|
||||
}, [discoverResults]);
|
||||
const discoverSummary = discoverReport
|
||||
? {
|
||||
scanned: discoverReport.scanned,
|
||||
added: discoverReport.edges_added,
|
||||
updated: discoverReport.edges_updated,
|
||||
stale: discoverReport.edges_stale || 0,
|
||||
failed: discoverReport.results.filter((r) => !r.ok).length,
|
||||
failed: discoverCounts.fail,
|
||||
}
|
||||
: {
|
||||
scanned: discoverLiveResults.length,
|
||||
added: discoverProgress.edgesAdded,
|
||||
updated: discoverProgress.edgesUpdated,
|
||||
stale: 0,
|
||||
failed: discoverLiveResults.filter((r) => !r.ok).length,
|
||||
failed: discoverCounts.fail,
|
||||
};
|
||||
const discoverFiltered = useMemo(() => {
|
||||
return discoverResults.filter((r) => {
|
||||
const k = discoverResultKind(r);
|
||||
if (discoverFilter === "all") return true;
|
||||
if (discoverFilter === "issues") return k === "fail" || k === "warn";
|
||||
return k === discoverFilter;
|
||||
});
|
||||
}, [discoverResults, discoverFilter]);
|
||||
const DISCOVER_LIST_CAP = 200;
|
||||
const discoverVisible = discoverFiltered.slice(0, DISCOVER_LIST_CAP);
|
||||
const discoverPct =
|
||||
discovering && discoverProgress.total > 0
|
||||
? Math.min(100, Math.round((discoverProgress.index / discoverProgress.total) * 100))
|
||||
: discoverReport
|
||||
? 100
|
||||
: 0;
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
(connection: Connection | Edge) => {
|
||||
|
|
@ -1256,6 +1337,38 @@ export function TopologyPage() {
|
|||
showError(t("topology.noNeLink"));
|
||||
};
|
||||
|
||||
const openPortTrafficForEdge = (edge: Edge | null) => {
|
||||
closeCtxMenu();
|
||||
if (!edge) {
|
||||
showError(t("topology.noNeLink"));
|
||||
return;
|
||||
}
|
||||
const srcNode = nodes.find((n) => n.id === edge.source);
|
||||
const data = (edge.data || {}) as EdgeStyleData;
|
||||
const ifname = String(data.source_port || "").trim();
|
||||
const managedId = String(srcNode?.data.managed_ne_id || "").trim();
|
||||
const umeId = String(srcNode?.data.ume_ne_id || "").trim();
|
||||
if (managedId) {
|
||||
const q = new URLSearchParams({ ne_id: managedId, source: "managed" });
|
||||
if (ifname) q.set("ifname", ifname);
|
||||
openOrFocusModule({
|
||||
moduleId: "network",
|
||||
path: `/network/tasks/port-traffic?${q.toString()}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (umeId) {
|
||||
const q = new URLSearchParams({ ne_id: umeId, source: "ume" });
|
||||
if (ifname) q.set("ifname", ifname);
|
||||
openOrFocusModule({
|
||||
moduleId: "network",
|
||||
path: `/network/tasks/port-traffic?${q.toString()}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
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();
|
||||
|
|
@ -1292,6 +1405,83 @@ export function TopologyPage() {
|
|||
[setNodes, setEdges],
|
||||
);
|
||||
|
||||
const locateNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
focusNode(nodeId, false);
|
||||
setSearchHitIds([nodeId]);
|
||||
if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current);
|
||||
window.setTimeout(() => {
|
||||
rfRef.current?.fitView({ nodes: [{ id: nodeId }], padding: 0.45, duration: 280 });
|
||||
}, 30);
|
||||
searchHitTimerRef.current = window.setTimeout(() => {
|
||||
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
|
||||
searchHitTimerRef.current = null;
|
||||
}, 2200);
|
||||
},
|
||||
[focusNode],
|
||||
);
|
||||
|
||||
const canvasHits = useMemo(() => {
|
||||
const q = canvasQuery.trim();
|
||||
if (!q) return [];
|
||||
return nodes.filter((n) => nodeMatchesQuery(n, q));
|
||||
}, [canvasQuery, nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = canvasQuery.trim();
|
||||
if (!q) {
|
||||
setSearchHitIds([]);
|
||||
setFindActiveIdx(0);
|
||||
setFindOpen(false);
|
||||
return;
|
||||
}
|
||||
setFindOpen(true);
|
||||
setSearchHitIds(canvasHits.map((n) => n.id));
|
||||
setFindActiveIdx((idx) => (canvasHits.length ? Math.min(idx, canvasHits.length - 1) : 0));
|
||||
}, [canvasQuery, canvasHits]);
|
||||
|
||||
const findOnCanvas = useCallback(
|
||||
(nodeId?: string) => {
|
||||
const q = canvasQuery.trim();
|
||||
if (!q) return;
|
||||
if (!canvasHits.length) {
|
||||
showError(t("topology.findNoMatch"));
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
nodeId ||
|
||||
canvasHits[Math.max(0, Math.min(findActiveIdx, canvasHits.length - 1))]?.id ||
|
||||
canvasHits[0].id;
|
||||
setFindOpen(false);
|
||||
locateNode(id);
|
||||
},
|
||||
[canvasQuery, canvasHits, findActiveIdx, locateNode, showError, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!findBoxRef.current) return;
|
||||
if (!findBoxRef.current.contains(e.target as HTMLElement)) setFindOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, []);
|
||||
const locatePaletteItem = useCallback(
|
||||
(item: PaletteItem) => {
|
||||
const node = nodes.find((n) =>
|
||||
item.source === "managed"
|
||||
? n.data.managed_ne_id === item.managed_ne_id
|
||||
: n.data.ume_ne_id === item.ume_ne_id,
|
||||
);
|
||||
if (!node) {
|
||||
showError(t("topology.findNoMatch"));
|
||||
return;
|
||||
}
|
||||
locateNode(node.id);
|
||||
},
|
||||
[nodes, locateNode, showError, t],
|
||||
);
|
||||
|
||||
const focusEdge = useCallback(
|
||||
(edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
|
|
@ -1343,7 +1533,7 @@ export function TopologyPage() {
|
|||
name,
|
||||
ip: ne.ip_address || "",
|
||||
vendor: "ZTE",
|
||||
meta: `${ne.ip_address || "-"} ? ${ne.ne_type || "UME"}`,
|
||||
meta: `${ne.ip_address || "-"} · ${ne.ne_type || "UME"}`,
|
||||
connect_status: ne.connection_status || "",
|
||||
};
|
||||
});
|
||||
|
|
@ -1356,18 +1546,30 @@ export function TopologyPage() {
|
|||
name: ne.name || ne.ip_address,
|
||||
ip: ne.ip_address,
|
||||
vendor: ne.vendor,
|
||||
meta: `${ne.ip_address} ? ${ne.vendor}`,
|
||||
meta: `${ne.ip_address} · ${ne.vendor}`,
|
||||
connect_status: ne.connect_status,
|
||||
}));
|
||||
}, [paletteSource, neQuery.data, umeQuery.data]);
|
||||
const paletteVisible = useMemo(() => {
|
||||
if (!hideAddedNes) return palette;
|
||||
return palette.filter((item) =>
|
||||
const tokens = keyword
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
let list = palette;
|
||||
if (tokens.length) {
|
||||
list = list.filter((item) => {
|
||||
const bits = [item.name, item.ip, item.vendor, item.managed_ne_id, item.ume_ne_id, item.meta];
|
||||
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
|
||||
});
|
||||
}
|
||||
if (!hideAddedNes) return list;
|
||||
return list.filter((item) =>
|
||||
item.source === "managed"
|
||||
? !onCanvasManagedIds.has(item.managed_ne_id)
|
||||
: !onCanvasUmeIds.has(item.ume_ne_id),
|
||||
);
|
||||
}, [palette, hideAddedNes, onCanvasManagedIds, onCanvasUmeIds]);
|
||||
}, [palette, hideAddedNes, onCanvasManagedIds, onCanvasUmeIds, keyword]);
|
||||
const paletteLoading =
|
||||
(paletteSource === "managed" && neQuery.isLoading) ||
|
||||
(paletteSource === "ume" && umeQuery.isLoading);
|
||||
|
|
@ -1384,7 +1586,7 @@ export function TopologyPage() {
|
|||
onClick={() => setSidebarCollapsed(false)}
|
||||
>
|
||||
<span className="topo-sidebar__rail-icon" aria-hidden="true">
|
||||
›
|
||||
?
|
||||
</span>
|
||||
<span className="topo-sidebar__rail-label">{t("topology.maps")}</span>
|
||||
</button>
|
||||
|
|
@ -1412,7 +1614,7 @@ export function TopologyPage() {
|
|||
aria-label={t("topology.collapseSidebar")}
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
>
|
||||
‹
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1446,7 +1648,7 @@ export function TopologyPage() {
|
|||
disabled={renameMapMut.isPending}
|
||||
onClick={() => promptRenameMap(m.id, m.name)}
|
||||
>
|
||||
✎
|
||||
?
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1457,7 +1659,7 @@ export function TopologyPage() {
|
|||
if (window.confirm(msg)) deleteMapMut.mutate(m.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1531,11 +1733,20 @@ export function TopologyPage() {
|
|||
<button
|
||||
type="button"
|
||||
className="topo-palette__item"
|
||||
disabled={!mapId || onCanvas}
|
||||
disabled={!mapId}
|
||||
draggable={Boolean(mapId) && !onCanvas}
|
||||
onDragStart={(e) => onPaletteDragStart(e, item)}
|
||||
onClick={() => addPaletteItem(item)}
|
||||
title={`${item.name}\n${item.ip}`}
|
||||
onDragStart={(e) => {
|
||||
if (onCanvas) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onPaletteDragStart(e, item);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (onCanvas) locatePaletteItem(item);
|
||||
else addPaletteItem(item);
|
||||
}}
|
||||
title={`${item.name}\n${item.ip}${onCanvas ? `\n${t("topology.locateOnCanvas")}` : ""}`}
|
||||
>
|
||||
<span className="topo-palette__name">{item.name}</span>
|
||||
<span className="topo-palette__meta">
|
||||
|
|
@ -1652,6 +1863,86 @@ export function TopologyPage() {
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="topo-toolbar__group topo-toolbar__group--find"
|
||||
aria-label={t("topology.findNode")}
|
||||
ref={findBoxRef}
|
||||
>
|
||||
<input
|
||||
className="topo-toolbar__find"
|
||||
value={canvasQuery}
|
||||
onChange={(e) => setCanvasQuery(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (canvasQuery.trim()) setFindOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (!canvasHits.length) return;
|
||||
setFindOpen(true);
|
||||
setFindActiveIdx((i) => (i + 1) % canvasHits.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!canvasHits.length) return;
|
||||
setFindOpen(true);
|
||||
setFindActiveIdx((i) => (i - 1 + canvasHits.length) % canvasHits.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setFindOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
findOnCanvas();
|
||||
}
|
||||
}}
|
||||
placeholder={t("topology.findNodePh")}
|
||||
disabled={!mapId || nodes.length === 0}
|
||||
aria-label={t("topology.findNode")}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={findOpen}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!mapId || nodes.length === 0 || !canvasQuery.trim()}
|
||||
onClick={() => findOnCanvas()}
|
||||
>
|
||||
{t("topology.findNode")}
|
||||
</button>
|
||||
{findOpen && canvasQuery.trim() ? (
|
||||
<div className="topo-find-suggest" role="listbox">
|
||||
{canvasHits.length === 0 ? (
|
||||
<div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div>
|
||||
) : (
|
||||
canvasHits.slice(0, 12).map((n, idx) => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={idx === findActiveIdx}
|
||||
className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
|
||||
onMouseEnter={() => setFindActiveIdx(idx)}
|
||||
onClick={() => findOnCanvas(n.id)}
|
||||
>
|
||||
<span className="topo-find-suggest__name">{n.data.label || n.id}</span>
|
||||
<span className="topo-find-suggest__meta">
|
||||
{[n.data.ne_ip, n.data.vendor].filter(Boolean).join(" ? ")}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{canvasHits.length > 12 ? (
|
||||
<div className="topo-find-suggest__more">
|
||||
{t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="topo-toolbar__group" aria-label={t("topology.layout")}>
|
||||
<select
|
||||
className="topo-toolbar__select"
|
||||
|
|
@ -1826,18 +2117,27 @@ export function TopologyPage() {
|
|||
{discovering ? t("topology.discoverCancel") : t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
{discovering ? (
|
||||
<p className="panel__hint panel__hint--live">
|
||||
{discoverProgress.total <= 0
|
||||
? t("topology.discoverNoTargets")
|
||||
: t("topology.discoverProgressLive")
|
||||
{discovering && discoverProgress.total > 0 ? (
|
||||
<div className="topo-discover__progress" aria-live="polite">
|
||||
<div className="topo-discover__bar">
|
||||
<div className="topo-discover__bar-fill" style={{ width: `${discoverPct}%` }} />
|
||||
</div>
|
||||
<div className="topo-discover__progress-meta">
|
||||
<span>
|
||||
{t("topology.discoverProgressLive")
|
||||
.replace("{{index}}", String(discoverProgress.index))
|
||||
.replace("{{total}}", String(discoverProgress.total))
|
||||
.replace(
|
||||
"{{name}}",
|
||||
discoverProgress.neName || discoverProgress.neIp || "?",
|
||||
)}
|
||||
</p>
|
||||
</span>
|
||||
<span>{discoverPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{discovering && discoverProgress.total <= 0 ? (
|
||||
<p className="panel__hint panel__hint--live">{t("topology.discoverNoTargets")}</p>
|
||||
) : null}
|
||||
{!discovering && discoverReport && discoverReport.scanned === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverNoTargets")}</p>
|
||||
|
|
@ -1853,28 +2153,40 @@ export function TopologyPage() {
|
|||
.replace("{{stale}}", String(discoverSummary.stale))
|
||||
.replace("{{failed}}", String(discoverSummary.failed))}
|
||||
</p>
|
||||
<ul className="topo-discover__list">
|
||||
{discoverResults.map((r) => (
|
||||
<li key={r.ne_id} className={r.ok ? "is-ok" : "is-fail"}>
|
||||
<div className="topo-discover__item-title">
|
||||
<span>{r.ne_name || r.ne_ip || r.ne_id}</span>
|
||||
<span className="topo-discover__badge">{r.ok ? "OK" : "FAIL"}</span>
|
||||
</div>
|
||||
<div className="topo-discover__item-meta">
|
||||
{r.ne_ip ? `${r.ne_ip} ? ` : ""}
|
||||
{r.ok
|
||||
? t("topology.discoverNeOk")
|
||||
.replace("{{neighbors}}", String(r.neighbors))
|
||||
.replace("{{added}}", String(r.edges_added))
|
||||
.replace("{{updated}}", String(r.edges_updated))
|
||||
: r.error || t("topology.discoverNeFail")}
|
||||
</div>
|
||||
{r.command ? (
|
||||
<div className="topo-discover__item-cmd">{r.command}</div>
|
||||
) : null}
|
||||
</li>
|
||||
<div className="topo-discover__filters" role="group" aria-label={t("topology.discoverFilter")}>
|
||||
{(
|
||||
[
|
||||
["issues", t("topology.discoverFilterIssues"), discoverCounts.issues],
|
||||
["fail", t("topology.discoverFilterFail"), discoverCounts.fail],
|
||||
["warn", t("topology.discoverFilterWarn"), discoverCounts.warn],
|
||||
["ok", t("topology.discoverFilterOk"), discoverCounts.ok],
|
||||
] as const
|
||||
).map(([key, label, count]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="topo-discover__filter"
|
||||
onClick={() => {
|
||||
setDiscoverFilter(key);
|
||||
setDiscoverListOpen(true);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span className="topo-discover__filter-count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-discover__filter topo-discover__filter--primary"
|
||||
onClick={() => {
|
||||
setDiscoverFilter("all");
|
||||
setDiscoverListOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("topology.discoverFilterDetail")}
|
||||
<span className="topo-discover__filter-count">{discoverResults.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1889,7 +2201,11 @@ export function TopologyPage() {
|
|||
{mapId ? (
|
||||
<TopoDisplayContext.Provider value={displayOpts}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
nodes={nodes.map((n) =>
|
||||
searchHitIds.includes(n.id)
|
||||
? { ...n, className: "is-search-hit" }
|
||||
: { ...n, className: undefined },
|
||||
)}
|
||||
edges={displayEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
|
|
@ -2183,6 +2499,16 @@ export function TopologyPage() {
|
|||
</div>
|
||||
</li>
|
||||
<li className="topo-ctx__sep" aria-hidden />
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-ctx__item"
|
||||
role="menuitem"
|
||||
onClick={() => openPortTrafficForEdge(selectedEdge)}
|
||||
>
|
||||
{t("topology.openPortTraffic")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -2209,6 +2535,319 @@ export function TopologyPage() {
|
|||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
|
||||
{discoverListOpen ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setDiscoverListOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="modal modal--wide topo-discover-modal topo-discover-list-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="topo-discover-list-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="topo-discover-modal__head">
|
||||
<div>
|
||||
<h3 id="topo-discover-list-title">{t("topology.discoverListTitle")}</h3>
|
||||
<p className="topo-discover-modal__sub">
|
||||
{t("topology.discoverSummary")
|
||||
.replace("{{scanned}}", String(discoverSummary.scanned))
|
||||
.replace("{{added}}", String(discoverSummary.added))
|
||||
.replace("{{updated}}", String(discoverSummary.updated))
|
||||
.replace("{{stale}}", String(discoverSummary.stale))
|
||||
.replace("{{failed}}", String(discoverSummary.failed))}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setDiscoverListOpen(false)}
|
||||
>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="topo-discover__filters" role="tablist" aria-label={t("topology.discoverFilter")}>
|
||||
{(
|
||||
[
|
||||
["issues", t("topology.discoverFilterIssues"), discoverCounts.issues],
|
||||
["fail", t("topology.discoverFilterFail"), discoverCounts.fail],
|
||||
["warn", t("topology.discoverFilterWarn"), discoverCounts.warn],
|
||||
["ok", t("topology.discoverFilterOk"), discoverCounts.ok],
|
||||
["all", t("topology.discoverFilterAll"), discoverResults.length],
|
||||
] as const
|
||||
).map(([key, label, count]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={discoverFilter === key}
|
||||
className={`topo-discover__filter${discoverFilter === key ? " is-active" : ""}`}
|
||||
onClick={() => setDiscoverFilter(key)}
|
||||
>
|
||||
{label}
|
||||
<span className="topo-discover__filter-count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{discoverVisible.length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverListEmpty")}</p>
|
||||
) : (
|
||||
<ul className="topo-discover__list topo-discover__list--modal">
|
||||
{discoverVisible.map((r) => {
|
||||
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
|
||||
const kind = discoverResultKind(r);
|
||||
const title = r.ne_name || r.ne_ip || r.ne_id;
|
||||
const linkCount = r.links?.length || 0;
|
||||
return (
|
||||
<li key={r.ne_id} className={`is-${kind}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-discover__row"
|
||||
onClick={() => setDiscoverDetail(r)}
|
||||
>
|
||||
<span className="topo-discover__row-main">
|
||||
<span className="topo-discover__row-name">{title}</span>
|
||||
{r.ne_ip ? <span className="topo-discover__row-ip">{r.ne_ip}</span> : null}
|
||||
<span className="topo-discover__row-meta">
|
||||
{r.ok
|
||||
? t("topology.discoverNeOk")
|
||||
.replace("{{neighbors}}", String(r.neighbors))
|
||||
.replace("{{added}}", String(r.edges_added))
|
||||
.replace("{{updated}}", String(r.edges_updated))
|
||||
: r.error || t("topology.discoverNeFail")}
|
||||
{linkCount > 0
|
||||
? ` ? ${t("topology.discoverLinkCount").replace("{{count}}", String(linkCount))}`
|
||||
: ""}
|
||||
{r.ok && unmatchedCount > 0
|
||||
? ` ? ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="topo-discover__row-actions">
|
||||
<span className="topo-discover__badge">
|
||||
{kind === "fail"
|
||||
? "FAIL"
|
||||
: kind === "warn"
|
||||
? r.parser_stub
|
||||
? "STUB"
|
||||
: "WARN"
|
||||
: "OK"}
|
||||
</span>
|
||||
<span className="topo-discover__detail-btn">{t("topology.discoverViewDetail")}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{discoverFiltered.length > DISCOVER_LIST_CAP ? (
|
||||
<p className="topo-discover__cap">
|
||||
{t("topology.discoverShowLimited")
|
||||
.replace("{{shown}}", String(DISCOVER_LIST_CAP))
|
||||
.replace("{{total}}", String(discoverFiltered.length))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
{discoverDetail ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setDiscoverDetail(null)}
|
||||
>
|
||||
<div
|
||||
className="modal modal--wide topo-discover-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="topo-discover-detail-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="topo-discover-modal__head">
|
||||
<div>
|
||||
<h3 id="topo-discover-detail-title">
|
||||
{discoverDetail.ne_name || discoverDetail.ne_ip || discoverDetail.ne_id}
|
||||
</h3>
|
||||
<p className="topo-discover-modal__sub">
|
||||
{[discoverDetail.ne_ip, discoverDetail.command].filter(Boolean).join(" ? ")}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`topo-discover__badge topo-discover__badge--lg is-${discoverResultKind(discoverDetail)}`}
|
||||
>
|
||||
{discoverResultKind(discoverDetail) === "fail"
|
||||
? "FAIL"
|
||||
: discoverResultKind(discoverDetail) === "warn"
|
||||
? discoverDetail.parser_stub
|
||||
? "STUB"
|
||||
: "WARN"
|
||||
: "OK"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!discoverDetail.ok ? (
|
||||
<p className="topo-discover__error">
|
||||
{discoverDetail.error || t("topology.discoverNeFail")}
|
||||
</p>
|
||||
) : null}
|
||||
{discoverDetail.parser_stub ? (
|
||||
<p className="topo-discover__item-warn">
|
||||
{t("topology.discoverParserStub").replace(
|
||||
"{{parser}}",
|
||||
discoverDetail.parser_key || "unknown",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="topo-discover-modal__stats">
|
||||
<span>
|
||||
{t("topology.discoverNeOk")
|
||||
.replace("{{neighbors}}", String(discoverDetail.neighbors || 0))
|
||||
.replace("{{added}}", String(discoverDetail.edges_added || 0))
|
||||
.replace("{{updated}}", String(discoverDetail.edges_updated || 0))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="topo-discover-modal__section">
|
||||
{t("topology.discoverLinksTitle").replace(
|
||||
"{{count}}",
|
||||
String(discoverDetail.links?.length || 0),
|
||||
)}
|
||||
</h4>
|
||||
{(discoverDetail.links || []).length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverLinksEmpty")}</p>
|
||||
) : (
|
||||
<div className="topo-discover-modal__table-wrap">
|
||||
<table className="topo-discover-modal__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("topology.discoverColPeer")}</th>
|
||||
<th>{t("topology.discoverColLocalPort")}</th>
|
||||
<th>{t("topology.discoverColRemotePort")}</th>
|
||||
<th>{t("topology.discoverColProtocol")}</th>
|
||||
<th>{t("topology.discoverColAction")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(discoverDetail.links || []).map((link, idx) => (
|
||||
<tr key={`${discoverDetail.ne_id}-l-${idx}`}>
|
||||
<td>
|
||||
<div className="topo-discover-modal__peer">
|
||||
<strong>{link.peer_name || link.peer_ip || link.peer_ne_id || "?"}</strong>
|
||||
{link.peer_ip ? <span>{link.peer_ip}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{link.local_port || "?"}</td>
|
||||
<td>{link.remote_port || "?"}</td>
|
||||
<td>{(link.protocol || "?").toUpperCase()}</td>
|
||||
<td>
|
||||
<span className={`topo-discover-action is-${link.action || ""}`}>
|
||||
{link.action === "added"
|
||||
? t("topology.discoverLinkAdded")
|
||||
: link.action === "updated"
|
||||
? t("topology.discoverLinkUpdated")
|
||||
: link.action === "kept_manual"
|
||||
? t("topology.discoverLinkKeptManual")
|
||||
: link.action || "?"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{link.peer_node_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
const nid = String(link.peer_node_id || "");
|
||||
setDiscoverDetail(null);
|
||||
locateNode(nid);
|
||||
}}
|
||||
>
|
||||
{t("topology.discoverLocatePeer")}
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 className="topo-discover-modal__section">
|
||||
{t("topology.discoverUnmatchedTitle").replace(
|
||||
"{{count}}",
|
||||
String(discoverDetail.unmatched_count ?? discoverDetail.unmatched?.length ?? 0),
|
||||
)}
|
||||
</h4>
|
||||
{(discoverDetail.unmatched || []).length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
|
||||
) : (
|
||||
<div className="topo-discover-modal__table-wrap">
|
||||
<table className="topo-discover-modal__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("topology.discoverColRemote")}</th>
|
||||
<th>{t("topology.discoverColLocalPort")}</th>
|
||||
<th>{t("topology.discoverColRemotePort")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(discoverDetail.unmatched || []).map((u, idx) => (
|
||||
<tr key={`${discoverDetail.ne_id}-u-${idx}`}>
|
||||
<td>
|
||||
{(u.remote_name || u.remote_ip || "?").trim()}
|
||||
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
|
||||
</td>
|
||||
<td>{u.local_port || "?"}</td>
|
||||
<td>{u.remote_port || "?"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discoverDetail.raw_preview ? (
|
||||
<>
|
||||
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
|
||||
<pre className="topo-discover-modal__raw">{discoverDetail.raw_preview}</pre>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
const id = String(discoverDetail.ne_id || "");
|
||||
const node = nodes.find(
|
||||
(n) => n.data.managed_ne_id === id || n.data.ume_ne_id === id,
|
||||
);
|
||||
setDiscoverDetail(null);
|
||||
if (node) locateNode(node.id);
|
||||
else showError(t("topology.findNoMatch"));
|
||||
}}
|
||||
>
|
||||
{t("topology.discoverLocateNe")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--sm" onClick={() => setDiscoverDetail(null)}>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
collectPortTrafficNow,
|
||||
createPortTrafficTask,
|
||||
|
|
@ -48,6 +49,8 @@ export function PortTrafficPage() {
|
|||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deepLinkAppliedRef = useRef(false);
|
||||
|
||||
const [view, setView] = useState<"list" | "wizard" | "wall">("list");
|
||||
const [taskPage, setTaskPage] = useState(1);
|
||||
|
|
@ -73,6 +76,9 @@ export function PortTrafficPage() {
|
|||
Record<string, { loading?: boolean; error?: string; ports: PortTrafficDiscoverPort[]; meta?: { ne_name: string; ne_ip: string; vendor: string } }>
|
||||
>({});
|
||||
const [pickedPorts, setPickedPorts] = useState<Record<string, PortPick>>({});
|
||||
const [deepLinkHint, setDeepLinkHint] = useState("");
|
||||
const [deepLinkSource, setDeepLinkSource] = useState<"managed" | "ume" | "">("");
|
||||
const [deepLinkNeId, setDeepLinkNeId] = useState("");
|
||||
|
||||
const dashQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficDashboard,
|
||||
|
|
@ -150,6 +156,44 @@ export function PortTrafficPage() {
|
|||
? EMPTY_COMPARE_POINTS
|
||||
: (compareQuery.data?.baseline ?? EMPTY_COMPARE_POINTS);
|
||||
|
||||
useEffect(() => {
|
||||
if (deepLinkAppliedRef.current) return;
|
||||
const neId = (searchParams.get("ne_id") || "").trim();
|
||||
if (!neId) return;
|
||||
deepLinkAppliedRef.current = true;
|
||||
const source = searchParams.get("source") === "ume" ? "ume" : "managed";
|
||||
const ifname = (searchParams.get("ifname") || "").trim();
|
||||
setDeepLinkNeId(neId);
|
||||
setDeepLinkSource(source);
|
||||
setView("wizard");
|
||||
setStep(2);
|
||||
setNeKeyword(neId);
|
||||
setNePage(1);
|
||||
setDeepLinkHint(
|
||||
t("portTraffic.deepLinkHint")
|
||||
.replace("{{ne}}", neId)
|
||||
.replace(
|
||||
"{{ifname}}",
|
||||
ifname ? t("portTraffic.deepLinkIfname").replace("{{ifname}}", ifname) : "",
|
||||
),
|
||||
);
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [searchParams, setSearchParams, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deepLinkNeId || view !== "wizard" || step !== 2) return;
|
||||
const items = nesQuery.data?.items || [];
|
||||
const hit = items.find((row) => {
|
||||
const src = row.source === "ume" ? "ume" : "managed";
|
||||
if (deepLinkSource && src !== deepLinkSource) return false;
|
||||
return row.id === deepLinkNeId;
|
||||
});
|
||||
if (!hit) return;
|
||||
const k = neKey(hit.source, hit.id);
|
||||
setSelectedNes((prev) => (prev[k] ? prev : { ...prev, [k]: hit }));
|
||||
setDeepLinkNeId("");
|
||||
}, [deepLinkNeId, deepLinkSource, nesQuery.data, view, step]);
|
||||
|
||||
const invalidateAll = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTasksAll });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDashboard });
|
||||
|
|
@ -221,6 +265,9 @@ export function PortTrafficPage() {
|
|||
setPickedPorts({});
|
||||
setNeKeyword("");
|
||||
setNePage(1);
|
||||
setDeepLinkHint("");
|
||||
setDeepLinkSource("");
|
||||
setDeepLinkNeId("");
|
||||
};
|
||||
|
||||
const openWizard = () => {
|
||||
|
|
@ -542,6 +589,7 @@ export function PortTrafficPage() {
|
|||
|
||||
{step === 2 ? (
|
||||
<>
|
||||
{deepLinkHint ? <p className="panel__hint panel__hint--live">{deepLinkHint}</p> : null}
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={neKeyword}
|
||||
|
|
|
|||
|
|
@ -389,6 +389,24 @@ export type TopologyGraph = {
|
|||
edges: TopologyEdgeItem[];
|
||||
};
|
||||
|
||||
export type TopologyDiscoverUnmatched = {
|
||||
remote_name: string;
|
||||
remote_ip: string;
|
||||
local_port: string;
|
||||
remote_port: string;
|
||||
};
|
||||
|
||||
export type TopologyDiscoverLink = {
|
||||
peer_node_id?: string;
|
||||
peer_ne_id?: string;
|
||||
peer_name?: string;
|
||||
peer_ip?: string;
|
||||
local_port?: string;
|
||||
remote_port?: string;
|
||||
protocol?: string;
|
||||
action?: string;
|
||||
};
|
||||
|
||||
export type TopologyDiscoverNeResult = {
|
||||
ne_id: string;
|
||||
ne_name: string;
|
||||
|
|
@ -398,6 +416,11 @@ export type TopologyDiscoverNeResult = {
|
|||
neighbors: number;
|
||||
edges_added: number;
|
||||
edges_updated: number;
|
||||
unmatched_count?: number;
|
||||
unmatched?: TopologyDiscoverUnmatched[];
|
||||
links?: TopologyDiscoverLink[];
|
||||
parser_key?: string;
|
||||
parser_stub?: boolean;
|
||||
error: string;
|
||||
raw_preview: string;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue