mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 11:43:19 +08:00
Add topology classify inventory, fabric lifecycle, and broader collect targets.
Treat Fabric as an inventory sheet for role/region tagging and slices; detach fabric links on managed/UME delete instead of cascading map deletes. Extend collection to all managed and UME NEs, show NE source in management, and refine canvas vendor icon tones. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
90dff56e48
commit
a5a3a4f156
30 changed files with 5986 additions and 495 deletions
|
|
@ -14,6 +14,7 @@ import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage";
|
|||
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
|
||||
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
|
||||
import { LldpLinksPage } from "./pages/network/LldpLinksPage";
|
||||
import { TopologyClassifyPage } from "./pages/network/TopologyClassifyPage";
|
||||
import { PortTrafficPage } from "./pages/network/PortTrafficPage";
|
||||
import { PortTrafficBoardListPage } from "./pages/network/PortTrafficBoardListPage";
|
||||
import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage";
|
||||
|
|
@ -106,6 +107,7 @@ function ProtectedApp() {
|
|||
<Route path="alarms" element={<NetworkAlarmsPage />} />
|
||||
<Route path="configs" element={<NetworkConfigsPage />} />
|
||||
<Route path="topology/lldp" element={<LldpLinksPage />} />
|
||||
<Route path="topology/classify" element={<TopologyClassifyPage />} />
|
||||
<Route path="topology" element={<Navigate to="/network/topology/lldp" replace />} />
|
||||
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
|
||||
<Route path="tasks/collect" element={<CollectPage />} />
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
|
|||
labelKey: "network.nav.lldpLinks",
|
||||
group: "topologyMgmt",
|
||||
},
|
||||
{
|
||||
id: "topo-classify",
|
||||
path: "/network/topology/classify",
|
||||
labelKey: "network.nav.topoClassify",
|
||||
group: "topologyMgmt",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -47,6 +47,16 @@ export const queryKeys = {
|
|||
pageSize: number,
|
||||
) => ["umeCurrentAlarms", severity, cleared, hostName, keyword, page, pageSize] as const,
|
||||
topologyViews: ["topologyViews"] as const,
|
||||
topologyTree: ["topologyTree"] as const,
|
||||
fabricNodeInventory: (
|
||||
keyword: string,
|
||||
role: string,
|
||||
regionFolderId: string,
|
||||
unmatched: string,
|
||||
linkStatus: string,
|
||||
page: number,
|
||||
) => ["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page] as const,
|
||||
fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const,
|
||||
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
|
||||
fabricSummary: ["fabricSummary"] as const,
|
||||
/** @deprecated */
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const en = {
|
|||
devices: "Device list",
|
||||
topology: "Topology",
|
||||
lldpLinks: "LLDP links",
|
||||
topoClassify: "Classify & slices",
|
||||
alarms: "Alarms",
|
||||
configs: "Configurations",
|
||||
webcrt: "WebCRT",
|
||||
|
|
@ -473,8 +474,8 @@ const en = {
|
|||
selectedCount: "{{count}} selected",
|
||||
clearSelected: "Clear selection",
|
||||
remove: "Remove",
|
||||
pickTitle: "Eligible NEs (connectivity passed)",
|
||||
pickHint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
|
||||
pickTitle: "Eligible NEs",
|
||||
pickHint: "All managed and UME inventory NEs are selectable (any connect status). UME uses CLI connect profile credentials.",
|
||||
pickEmpty: "No matching NEs.",
|
||||
filterKeyword: "Filter",
|
||||
filterKeywordPh: "Name / IP / vendor / device type",
|
||||
|
|
@ -488,9 +489,9 @@ const en = {
|
|||
meta: "{{ne}} NE(s) selected · {{cmd}} command(s)",
|
||||
},
|
||||
eligible: {
|
||||
title: "Eligible NEs (connectivity passed)",
|
||||
hint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
|
||||
empty: "No NEs with passed connectivity test.",
|
||||
title: "Eligible NEs",
|
||||
hint: "All managed and UME inventory NEs are selectable (any connect status).",
|
||||
empty: "No NEs.",
|
||||
},
|
||||
form: {
|
||||
title: "Collection setup",
|
||||
|
|
@ -591,6 +592,7 @@ const en = {
|
|||
name: "Name",
|
||||
vendor: "Vendor",
|
||||
deviceType: "Device type",
|
||||
source: "Source",
|
||||
ip: "Management IP",
|
||||
port: "Port",
|
||||
protocol: "Protocol",
|
||||
|
|
@ -602,6 +604,12 @@ const en = {
|
|||
testedAt: "Tested at",
|
||||
actions: "Actions",
|
||||
},
|
||||
source: {
|
||||
manual: "Manual",
|
||||
ume_sync: "UME sync",
|
||||
webcrt: "WebCRT",
|
||||
lldp: "LLDP placeholder",
|
||||
},
|
||||
connect: {
|
||||
run: "Connectivity test",
|
||||
running: "Testing…",
|
||||
|
|
@ -1185,6 +1193,43 @@ const en = {
|
|||
topology: {
|
||||
title: "Topology",
|
||||
maps: "Maps",
|
||||
tree: "Topology tree",
|
||||
treeLoading: "Loading tree…",
|
||||
treeHint: "Regions hold a physical map plus optional custom maps; open a map to edit the canvas.",
|
||||
rootName: "Regions",
|
||||
selectRegion: "Select a region",
|
||||
openMap: "Open map",
|
||||
kindPhysical: "Physical",
|
||||
kindCustom: "Custom",
|
||||
regionNodeHint: "{{count}} map(s)",
|
||||
layerNodeHint: "{{count}} NE(s)",
|
||||
layerNodeEmpty: "Click to create & open",
|
||||
rootCanvasHint: "Pick a region from the left tree.",
|
||||
regionCanvasHint: "Open a map to edit devices, or create a custom map.",
|
||||
regionBrowseHint: "Region folder: physical + custom maps. Click to open.",
|
||||
browserTitle: "Topology folder",
|
||||
browserRegionSub: "{{count}} map(s)",
|
||||
browserEmptyMaps: "No maps in this region yet.",
|
||||
addNe: "Add NE",
|
||||
backUp: "Back to region",
|
||||
newRegion: "New region",
|
||||
newRegionName: "New region",
|
||||
newRegionPrompt: "Region name",
|
||||
regionCreated: "Region created",
|
||||
regionDeleted: "Region deleted",
|
||||
deleteRegion: "Delete region",
|
||||
deleteRegionConfirm: "Delete region \"{{name}}\"? Its physical and custom maps will also be deleted.",
|
||||
renameRegion: "Rename region",
|
||||
renameRegionPrompt: "Region name",
|
||||
regionRenamed: "Region renamed",
|
||||
collapseRegion: "Collapse region",
|
||||
expandRegion: "Expand region",
|
||||
newCustom: "Custom map",
|
||||
newCustomName: "Custom topology",
|
||||
newCustomPrompt: "Custom map name",
|
||||
folderHint: "Regions group maps. Each region gets a physical map; add custom maps as needed.",
|
||||
folderHintShort: "Region folder",
|
||||
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
||||
newMap: "New",
|
||||
newMapName: "New topology",
|
||||
rename: "Rename",
|
||||
|
|
@ -1193,7 +1238,7 @@ const en = {
|
|||
renamed: "Topology map renamed",
|
||||
deleteMap: "Delete map",
|
||||
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
|
||||
emptyMaps: "No maps yet. Create one to start.",
|
||||
emptyMaps: "No regions yet. Create a region first.",
|
||||
palette: "NE library",
|
||||
paletteHint: "Click or drag onto the canvas",
|
||||
filterPh: "Type to filter: name / IP / vendor",
|
||||
|
|
@ -1214,7 +1259,7 @@ const en = {
|
|||
sourcePort: "Local port",
|
||||
targetPort: "Remote port",
|
||||
canvasHint:
|
||||
"Add NEs from the palette, drag then save positions; LLDP discovery writes the global fabric. Blue dashed=discovered, red=stale.",
|
||||
"Left tree lists regions with a physical map and optional custom maps. Open a map to edit devices; use Add NE and save positions.",
|
||||
selected: "Selected",
|
||||
selectedEdge: "Selected edge",
|
||||
selectedCount: "{{count}} selected",
|
||||
|
|
@ -1337,6 +1382,66 @@ const en = {
|
|||
staleRemoved: "Removed {{count}} missing edge(s)",
|
||||
connectHint: "Connect mode: click source node, then target (Esc or Select to exit)",
|
||||
},
|
||||
topoClassify: {
|
||||
title: "Classify & slices",
|
||||
openTopo: "Topology",
|
||||
inventory: "NE inventory",
|
||||
keywordPh: "Filter by name/IP",
|
||||
filterRoleAll: "All roles",
|
||||
filterRegionAll: "All regions",
|
||||
filterUnmatchedOff: "No unmatched filter",
|
||||
kindAny: "Role or region empty",
|
||||
kindRole: "Role empty",
|
||||
kindRegion: "Region empty",
|
||||
filterLinkAll: "All link states",
|
||||
filterLinkLinked: "Linked inventory",
|
||||
filterLinkOrphaned: "Detached",
|
||||
colLink: "Link",
|
||||
linkManaged: "Managed",
|
||||
linkUme: "UME",
|
||||
linkBoth: "Managed+UME",
|
||||
linkOrphaned: "Detached",
|
||||
regexTitle: "Regex find",
|
||||
patternPh: "Regex, e.g. ^CORE- or -BJ-",
|
||||
fieldName: "Name",
|
||||
fieldNameIp: "Name+IP",
|
||||
findMatches: "Find matches",
|
||||
matchOk: "Matched {{count}}; selected",
|
||||
matchHint: "{{count}} matched",
|
||||
assignRole: "Role only",
|
||||
assignRegion: "Region only",
|
||||
assignBoth: "Role + region",
|
||||
clearRegion: "Clear region",
|
||||
confirmAssign: "Confirm assign",
|
||||
bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?",
|
||||
bulkOk: "Updated {{count}}",
|
||||
selectedCount: "{{count}} selected",
|
||||
selectPage: "Select page",
|
||||
colNe: "NE",
|
||||
colRole: "Role",
|
||||
colRegion: "Region",
|
||||
colActions: "Actions",
|
||||
edit: "Edit",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
rowSaved: "Saved",
|
||||
loading: "Loading…",
|
||||
empty: "No NEs.",
|
||||
showSlices: "Show slice maps",
|
||||
hideSlices: "Hide slice maps",
|
||||
slices: "Slice maps",
|
||||
pickRegion: "Pick region",
|
||||
tplCore: "Core only",
|
||||
tplCoreAgg: "Core + neighbor Agg",
|
||||
tplAggAcc: "Agg + neighbor Access",
|
||||
seedPhysical: "Also place Cores on physical overview",
|
||||
slicePreview: "Preview slices",
|
||||
sliceApply: "Generate maps",
|
||||
sliceConfirm: "Create custom maps and freeze placements?",
|
||||
sliceOk: "Created {{count}} slice map(s)",
|
||||
sliceStats: "{{maps}} map(s) planned, {{overlap}} NE(s) on multiple maps",
|
||||
nodeCount: "{{count}} NE(s)",
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const zh = {
|
|||
devices: "设备列表",
|
||||
topology: "拓扑信息",
|
||||
lldpLinks: "LLDP 链路",
|
||||
topoClassify: "分类与切片",
|
||||
alarms: "告警信息",
|
||||
configs: "配置信息",
|
||||
webcrt: "WebCRT",
|
||||
|
|
@ -472,8 +473,8 @@ const zh = {
|
|||
selectedCount: "共 {{count}} 台",
|
||||
clearSelected: "清空已选",
|
||||
remove: "移除",
|
||||
pickTitle: "可选网元(连通性已通过)",
|
||||
pickHint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
|
||||
pickTitle: "可选网元",
|
||||
pickHint: "可选网元管理与 UME 清单中的全部网元(不限连通性状态)。UME 使用 CLI 连接模板凭据。",
|
||||
pickEmpty: "没有匹配的网元。",
|
||||
filterKeyword: "筛选",
|
||||
filterKeywordPh: "名称 / IP / 厂商 / 设备类型",
|
||||
|
|
@ -487,9 +488,9 @@ const zh = {
|
|||
meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令",
|
||||
},
|
||||
eligible: {
|
||||
title: "可选网元(连通性已通过)",
|
||||
hint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
|
||||
empty: "暂无已通过连通性测试的网元。",
|
||||
title: "可选网元",
|
||||
hint: "可选网元管理与 UME 清单中的全部网元(不限连通性状态)。",
|
||||
empty: "暂无网元。",
|
||||
},
|
||||
form: {
|
||||
title: "采集配置",
|
||||
|
|
@ -589,6 +590,7 @@ const zh = {
|
|||
name: "名称",
|
||||
vendor: "厂商",
|
||||
deviceType: "设备类型",
|
||||
source: "来源",
|
||||
ip: "管理地址",
|
||||
port: "端口",
|
||||
protocol: "协议",
|
||||
|
|
@ -600,6 +602,12 @@ const zh = {
|
|||
testedAt: "测试时间",
|
||||
actions: "操作",
|
||||
},
|
||||
source: {
|
||||
manual: "手工",
|
||||
ume_sync: "UME 同步",
|
||||
webcrt: "WebCRT",
|
||||
lldp: "LLDP 占位",
|
||||
},
|
||||
connect: {
|
||||
run: "连通性测试",
|
||||
running: "测试中…",
|
||||
|
|
@ -1182,6 +1190,43 @@ const zh = {
|
|||
topology: {
|
||||
title: "拓扑管理",
|
||||
maps: "拓扑图",
|
||||
tree: "拓扑树",
|
||||
treeLoading: "加载拓扑树…",
|
||||
treeHint: "区域下为物理拓扑与自定义图;点图打开设备画布。",
|
||||
rootName: "区域",
|
||||
selectRegion: "请选择区域",
|
||||
openMap: "打开本图",
|
||||
kindPhysical: "物理拓扑",
|
||||
kindCustom: "自定义图",
|
||||
regionNodeHint: "{{count}} 张图",
|
||||
layerNodeHint: "{{count}} 个网元",
|
||||
layerNodeEmpty: "点击创建并打开",
|
||||
rootCanvasHint: "从左侧选择区域进入目录。",
|
||||
regionCanvasHint: "点击图打开设备画布,或新建自定义图。",
|
||||
regionBrowseHint: "区域目录:物理拓扑 + 自定义图。点击打开。",
|
||||
browserTitle: "拓扑目录",
|
||||
browserRegionSub: "共 {{count}} 张拓扑图",
|
||||
browserEmptyMaps: "此区域还没有拓扑图。",
|
||||
addNe: "添加网元",
|
||||
backUp: "返回区域",
|
||||
newRegion: "新建区域",
|
||||
newRegionName: "新区域",
|
||||
newRegionPrompt: "区域名称",
|
||||
regionCreated: "区域已创建",
|
||||
regionDeleted: "区域已删除",
|
||||
deleteRegion: "删除区域",
|
||||
deleteRegionConfirm: "删除区域「{{name}}」?其下物理拓扑与自定义图将一并删除。",
|
||||
renameRegion: "重命名区域",
|
||||
renameRegionPrompt: "区域名称",
|
||||
regionRenamed: "区域已重命名",
|
||||
collapseRegion: "折叠区域",
|
||||
expandRegion: "展开区域",
|
||||
newCustom: "自定义图",
|
||||
newCustomName: "自定义拓扑",
|
||||
newCustomPrompt: "自定义拓扑图名称",
|
||||
folderHint: "区域用于分组。每区有一张物理拓扑,可再加自定义图。",
|
||||
folderHintShort: "区域目录",
|
||||
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
||||
newMap: "新建",
|
||||
newMapName: "新拓扑图",
|
||||
rename: "重命名",
|
||||
|
|
@ -1190,7 +1235,7 @@ const zh = {
|
|||
renamed: "拓扑图已重命名",
|
||||
deleteMap: "删除图",
|
||||
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
|
||||
emptyMaps: "还没有拓扑图,先新建一张。",
|
||||
emptyMaps: "还没有区域,先点「新建区域」。",
|
||||
palette: "网元库",
|
||||
paletteHint: "点击或拖到画布添加",
|
||||
filterPh: "输入即筛:名称 / IP / 厂商",
|
||||
|
|
@ -1211,7 +1256,7 @@ const zh = {
|
|||
sourcePort: "本端端口",
|
||||
targetPort: "对端端口",
|
||||
canvasHint:
|
||||
"从网元库上图,拖拽后保存坐标;LLDP 发现写入全局 Fabric。蓝虚线=发现链路,红虚线=stale。",
|
||||
"左侧为区域目录(物理拓扑 + 自定义图);打开本图后进入设备画布,用「添加网元」上图并保存。",
|
||||
selected: "已选节点",
|
||||
selectedEdge: "已选链路",
|
||||
selectedCount: "已选 {{count}}",
|
||||
|
|
@ -1333,6 +1378,66 @@ const zh = {
|
|||
staleRemoved: "已删除 {{count}} 条未发现链路",
|
||||
connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)",
|
||||
},
|
||||
topoClassify: {
|
||||
title: "分类与切片",
|
||||
openTopo: "拓扑画布",
|
||||
inventory: "网元清单",
|
||||
keywordPh: "关键字筛选名称/IP",
|
||||
filterRoleAll: "全部角色",
|
||||
filterRegionAll: "全部区域",
|
||||
filterUnmatchedOff: "不过滤未分类",
|
||||
kindAny: "角色或区域未填",
|
||||
kindRole: "角色未填",
|
||||
kindRegion: "区域未填",
|
||||
filterLinkAll: "全部关联",
|
||||
filterLinkLinked: "已关联库存",
|
||||
filterLinkOrphaned: "已解绑",
|
||||
colLink: "关联",
|
||||
linkManaged: "Managed",
|
||||
linkUme: "UME",
|
||||
linkBoth: "Managed+UME",
|
||||
linkOrphaned: "已解绑",
|
||||
regexTitle: "正则查找",
|
||||
patternPh: "正则,如 ^CORE- 或 -BJ-",
|
||||
fieldName: "名称",
|
||||
fieldNameIp: "名称+IP",
|
||||
findMatches: "查找匹配",
|
||||
matchOk: "匹配 {{count}} 条,已勾选",
|
||||
matchHint: "当前匹配 {{count}} 条",
|
||||
assignRole: "只改角色",
|
||||
assignRegion: "只改区域",
|
||||
assignBoth: "角色+区域",
|
||||
clearRegion: "清空区域",
|
||||
confirmAssign: "确认写入",
|
||||
bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?",
|
||||
bulkOk: "已更新 {{count}} 条",
|
||||
selectedCount: "已选 {{count}}",
|
||||
selectPage: "全选本页",
|
||||
colNe: "网元",
|
||||
colRole: "角色",
|
||||
colRegion: "区域",
|
||||
colActions: "操作",
|
||||
edit: "编辑",
|
||||
save: "保存",
|
||||
cancel: "取消",
|
||||
rowSaved: "已保存",
|
||||
loading: "加载中…",
|
||||
empty: "没有网元。",
|
||||
showSlices: "展开切片建图",
|
||||
hideSlices: "收起切片建图",
|
||||
slices: "切片建图",
|
||||
pickRegion: "选择区域",
|
||||
tplCore: "仅 Core",
|
||||
tplCoreAgg: "Core + 邻接 Agg",
|
||||
tplAggAcc: "Agg + 邻接 Access",
|
||||
seedPhysical: "同时给物理总览上 Core",
|
||||
slicePreview: "预览切片",
|
||||
sliceApply: "生成切片图",
|
||||
sliceConfirm: "将创建自定义拓扑图并上图冻结,确定?",
|
||||
sliceOk: "已创建 {{count}} 张切片图",
|
||||
sliceStats: "计划 {{maps}} 张图,重复上图网元 {{overlap}} 个",
|
||||
nodeCount: "{{count}} 个网元",
|
||||
},
|
||||
};
|
||||
|
||||
export default zh;
|
||||
|
|
|
|||
|
|
@ -3461,12 +3461,24 @@ pre {
|
|||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 0;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
color: var(--nm-muted);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.topo-sidebar__rail .topo-sidebar__rail-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.topo-sidebar__rail:hover .topo-sidebar__rail-icon {
|
||||
border-color: var(--nm-nav-border);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topo-sidebar__rail:hover {
|
||||
background: var(--nm-nav-hover);
|
||||
color: var(--nm-brand);
|
||||
|
|
@ -3548,6 +3560,557 @@ pre {
|
|||
box-shadow: inset 0 0 0 1px #b8c4d2;
|
||||
}
|
||||
|
||||
.topo-page .topo-sidebar__section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.topo-dir {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #c8d2de;
|
||||
border-radius: 6px;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
.topo-dir__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.topo-dir__list--nested {
|
||||
margin: 0 0 2px 10px;
|
||||
padding: 2px 0 4px;
|
||||
border-left: 1px solid #d5dde8;
|
||||
}
|
||||
.topo-dir__block {
|
||||
list-style: none;
|
||||
}
|
||||
.topo-dir__block--sep {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #d5dde8;
|
||||
}
|
||||
.topo-dir__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
gap: 2px;
|
||||
margin: 0 4px;
|
||||
padding: 0 4px 0 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.topo-dir__row:hover {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.topo-dir__row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.topo-dir__row--root {
|
||||
min-height: 32px;
|
||||
background: #eef3f9;
|
||||
border-bottom: 1px solid #dbe3ee;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.topo-dir__row--file {
|
||||
min-height: 26px;
|
||||
}
|
||||
.topo-dir__row.is-active {
|
||||
background: #fff;
|
||||
box-shadow: inset 0 0 0 1px #b7c9e2;
|
||||
}
|
||||
.topo-dir__row.is-active .topo-dir__name {
|
||||
font-weight: 600;
|
||||
color: var(--nm-accent);
|
||||
}
|
||||
.topo-dir__twist {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 18px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.topo-dir__indent {
|
||||
width: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.topo-dir__item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 2px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.topo-dir__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
color: #475569;
|
||||
}
|
||||
.topo-dir__icon--root {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.topo-dir__icon--region {
|
||||
color: #0f766e;
|
||||
}
|
||||
.topo-dir__icon--core {
|
||||
color: #b45309;
|
||||
}
|
||||
.topo-dir__icon--aggregation {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.topo-dir__icon--access {
|
||||
color: #6d28d9;
|
||||
}
|
||||
.topo-dir__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12.5px;
|
||||
color: var(--nm-text);
|
||||
}
|
||||
.topo-dir__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.topo-dir__meta {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
.topo-dir__kind {
|
||||
flex: 0 0 auto;
|
||||
max-width: 4.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #64748b;
|
||||
background: #e8eef5;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.topo-dir__kind.is-physical {
|
||||
color: #9a3412;
|
||||
background: #ffedd5;
|
||||
}
|
||||
.topo-dir__badge {
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
min-width: 1.1rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: #e8eef5;
|
||||
border-radius: 8px;
|
||||
padding: 0 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.topo-dir__action {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
opacity: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.topo-dir__row:hover .topo-dir__action,
|
||||
.topo-dir__row.is-active .topo-dir__action {
|
||||
opacity: 1;
|
||||
}
|
||||
.topo-dir__action:hover {
|
||||
color: #334155;
|
||||
background: #e8eef5;
|
||||
}
|
||||
.topo-dir__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 2px 6px 6px 20px;
|
||||
}
|
||||
.topo-dir__toolbar button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
color: var(--nm-accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.topo-dir__toolbar button:hover {
|
||||
background: rgba(26, 86, 168, 0.08);
|
||||
}
|
||||
.topo-dir__hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.topo-dir__icon svg,
|
||||
.topo-tree__glyph-svg {
|
||||
display: block;
|
||||
}
|
||||
.topo-browser {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background:
|
||||
linear-gradient(180deg, #f4f7fb 0%, #eef2f7 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.topo-browser__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px 10px;
|
||||
border-bottom: 1px solid #dbe3ee;
|
||||
background: #fff;
|
||||
}
|
||||
.topo-browser__head strong {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--nm-brand);
|
||||
}
|
||||
.topo-browser__sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
.topo-browser__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.topo-browser__actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.topo-browser__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 20px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
.topo-browser__empty p {
|
||||
margin: 0;
|
||||
}
|
||||
.topo-browser__empty-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #d5dde8;
|
||||
color: #0f766e;
|
||||
box-shadow: 0 1px 2px rgba(15, 39, 68, 0.05);
|
||||
}
|
||||
.topo-browser__grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
padding: 16px 18px 20px;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.22) 1px, transparent 0);
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
.topo-map-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 168px;
|
||||
border: 1px solid #cfd8e6;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(15, 39, 68, 0.05);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.topo-map-card:hover {
|
||||
border-color: #9eb4d0;
|
||||
box-shadow: 0 4px 14px rgba(15, 39, 68, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.topo-map-card.is-physical {
|
||||
border-color: #f0c48a;
|
||||
background: linear-gradient(180deg, #fffdf8 0%, #fff 42%);
|
||||
}
|
||||
.topo-map-card__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 16px 14px 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
.topo-map-card__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #eef3f9;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #dbe3ee;
|
||||
}
|
||||
.topo-map-card.is-physical .topo-map-card__icon {
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
.topo-map-card__kind {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
color: #1d4ed8;
|
||||
background: #e8eef8;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.topo-map-card__kind.is-physical {
|
||||
color: #9a3412;
|
||||
background: #ffedd5;
|
||||
}
|
||||
.topo-map-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
color: var(--nm-brand);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
.topo-map-card__meta {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #64748b;
|
||||
width: 100%;
|
||||
}
|
||||
.topo-map-card__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
.topo-map-card__actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
.topo-map-card--add {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 168px;
|
||||
border-style: dashed;
|
||||
border-color: #b7c4d4;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
color: var(--nm-accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
.topo-map-card--add:hover {
|
||||
background: #fff;
|
||||
border-color: var(--nm-accent);
|
||||
color: var(--nm-brand);
|
||||
box-shadow: 0 2px 8px rgba(15, 39, 68, 0.06);
|
||||
}
|
||||
.topo-map-card__add-plus {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
border: 1px dashed currentColor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.topo-breadcrumb__crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.topo-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.topo-breadcrumb__link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--nm-accent, #2563eb);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
.topo-breadcrumb__sep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.topo-breadcrumb__current {
|
||||
font-weight: 600;
|
||||
}
|
||||
.topo-toolbar__level {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.topo-nav-node {
|
||||
min-width: 180px;
|
||||
max-width: 220px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #c9d4e2;
|
||||
background: linear-gradient(165deg, #ffffff 0%, #f3f7fb 100%);
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.07);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.topo-nav-node.is-selected,
|
||||
.topo-nav-node:hover {
|
||||
border-color: var(--nm-accent, #2563eb);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--nm-accent, #2563eb) 22%, transparent),
|
||||
0 8px 18px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
.topo-nav-node__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
color: #1d4ed8;
|
||||
background: color-mix(in srgb, #2563eb 12%, #fff);
|
||||
}
|
||||
.topo-nav-node--region .topo-nav-node__icon {
|
||||
color: #0f766e;
|
||||
background: color-mix(in srgb, #0d9488 14%, #fff);
|
||||
}
|
||||
.topo-nav-node--core .topo-nav-node__icon {
|
||||
color: #b45309;
|
||||
background: color-mix(in srgb, #f59e0b 16%, #fff);
|
||||
}
|
||||
.topo-nav-node--aggregation .topo-nav-node__icon {
|
||||
color: #1d4ed8;
|
||||
background: color-mix(in srgb, #3b82f6 14%, #fff);
|
||||
}
|
||||
.topo-nav-node--access .topo-nav-node__icon {
|
||||
color: #6d28d9;
|
||||
background: color-mix(in srgb, #8b5cf6 14%, #fff);
|
||||
}
|
||||
.topo-nav-node__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.topo-nav-node__badge {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.55;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.topo-nav-node__title {
|
||||
font-weight: 650;
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.topo-nav-node__sub,
|
||||
.topo-nav-node__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.68;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.topo-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.topo-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
.topo-modal__panel {
|
||||
position: relative;
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: min(70vh, 640px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
.topo-modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.topo-modal__list {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
.topo-map-list,
|
||||
.topo-palette {
|
||||
list-style: none;
|
||||
|
|
@ -3558,6 +4121,160 @@ pre {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.topo-classify .topo-classify__head-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.topo-classify__section {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #d5dde8;
|
||||
}
|
||||
.topo-classify__section--first {
|
||||
margin-top: 8px;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.topo-classify__regex-bar,
|
||||
.topo-classify__assign-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 0 0 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #d5dde8;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.topo-classify__regex-input {
|
||||
min-width: 220px;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
.topo-classify__table tr.is-match {
|
||||
background: #eef6ff;
|
||||
}
|
||||
.topo-classify__row-actions {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.topo-classify__section h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
color: var(--nm-brand, #0f2744);
|
||||
}
|
||||
.topo-classify__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.topo-classify__section-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.topo-classify__draft {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.topo-classify__draft .input {
|
||||
min-width: 140px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.topo-classify__preview {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d5dde8;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.topo-classify__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.topo-classify__check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
.topo-classify__slice-list,
|
||||
.topo-classify__view-links {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.topo-classify__view-links {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
.topo-classify__view-links a {
|
||||
color: var(--nm-accent, #1a56a8);
|
||||
}
|
||||
|
||||
.topo-region-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.topo-region-list__block {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topo-region-list__row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-active > .topo-region-list__row {
|
||||
border-color: var(--nm-accent);
|
||||
background: var(--nm-nav-active);
|
||||
box-shadow: inset 2px 0 0 var(--nm-accent);
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions {
|
||||
border-left-color: #9bb6d9;
|
||||
}
|
||||
|
||||
.topo-region-list__glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 5px;
|
||||
vertical-align: -2px;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.topo-region-list__maps {
|
||||
margin: 4px 0 0 18px;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.topo-region-list__maps > li {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.topo-region-list .topo-map-list__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.topo-region-list .topo-map-list__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topo-map-list li,
|
||||
.topo-palette > li {
|
||||
margin: 0 0 4px;
|
||||
|
|
@ -4793,33 +5510,50 @@ pre {
|
|||
box-shadow: none;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
/* Default asset is blue (= ZTE). */
|
||||
/* Base asset /topo/ne-router.png is navy-blue; tints approximate brand hues. */
|
||||
--topo-icon-filter: none;
|
||||
}
|
||||
|
||||
/* Cisco — cyan / light blue (#049FD9) */
|
||||
.topo-node--cisco {
|
||||
--topo-icon-filter: hue-rotate(-8deg) saturate(1.15);
|
||||
--topo-icon-filter: hue-rotate(-18deg) saturate(1.25) brightness(1.08);
|
||||
}
|
||||
/* Huawei — red (#CF0A2C); was incorrectly greenish at 155deg */
|
||||
.topo-node--huawei {
|
||||
--topo-icon-filter: hue-rotate(155deg) saturate(1.35);
|
||||
--topo-icon-filter: hue-rotate(128deg) saturate(1.85) brightness(1.02);
|
||||
}
|
||||
/* ZTE — keep navy-blue base (#0091DA family) */
|
||||
.topo-node--zte {
|
||||
--topo-icon-filter: none;
|
||||
--topo-icon-filter: saturate(1.15) brightness(1.02);
|
||||
}
|
||||
/* Juniper — green (#84B135) */
|
||||
.topo-node--juniper {
|
||||
--topo-icon-filter: hue-rotate(95deg) saturate(1.2);
|
||||
--topo-icon-filter: hue-rotate(78deg) saturate(1.35) brightness(1.05);
|
||||
}
|
||||
/* Nokia / Alcatel — deep blue-purple (#124191) */
|
||||
.topo-node--nokia {
|
||||
--topo-icon-filter: hue-rotate(-28deg) saturate(1.1) brightness(0.98);
|
||||
--topo-icon-filter: hue-rotate(-35deg) saturate(1.2) brightness(0.92);
|
||||
}
|
||||
/* Ericsson — dark navy */
|
||||
.topo-node--ericsson {
|
||||
--topo-icon-filter: hue-rotate(175deg) saturate(1.45) brightness(1.05);
|
||||
--topo-icon-filter: hue-rotate(-8deg) saturate(1.1) brightness(0.82);
|
||||
}
|
||||
/* H3C — green (#7AC143) */
|
||||
.topo-node--h3c {
|
||||
--topo-icon-filter: hue-rotate(165deg) saturate(1.4);
|
||||
--topo-icon-filter: hue-rotate(88deg) saturate(1.45) brightness(1.06);
|
||||
}
|
||||
/* Ruijie — purple */
|
||||
.topo-node--ruijie {
|
||||
--topo-icon-filter: hue-rotate(45deg) saturate(1.4) brightness(1.0);
|
||||
}
|
||||
/* MikroTik — red-orange */
|
||||
.topo-node--mikrotik {
|
||||
--topo-icon-filter: hue-rotate(145deg) saturate(1.7) brightness(1.05);
|
||||
}
|
||||
/* Unmanaged / unknown vendor */
|
||||
.topo-node--gray,
|
||||
.topo-node--other {
|
||||
--topo-icon-filter: grayscale(0.85) brightness(0.95);
|
||||
--topo-icon-filter: grayscale(1) brightness(0.92) contrast(0.95);
|
||||
}
|
||||
|
||||
.topo-node__glyph {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ import { formatSystemTime } from "../utils/time";
|
|||
const POLL_MS = 2000;
|
||||
const ELIGIBLE_PAGE_SIZE = 20;
|
||||
|
||||
function eligibleKey(row: Pick<EligibleNeItem, "id" | "source">): string {
|
||||
const src = String(row.source || "managed").trim().toLowerCase() || "managed";
|
||||
return `${src}:${row.id}`;
|
||||
}
|
||||
|
||||
function collectionErrorMessage(err: unknown, t: (key: string) => string): string {
|
||||
const raw = String(err);
|
||||
if (raw.includes("collection_ne_busy")) return t("collect.neBusy");
|
||||
|
|
@ -140,12 +145,20 @@ export function CollectPage() {
|
|||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createNeCollection({
|
||||
mutationFn: () => {
|
||||
const ne_ids: string[] = [];
|
||||
const ume_ne_ids: string[] = [];
|
||||
for (const row of selectedList) {
|
||||
if (String(row.source || "managed").toLowerCase() === "ume") ume_ne_ids.push(row.id);
|
||||
else ne_ids.push(row.id);
|
||||
}
|
||||
return createNeCollection({
|
||||
title: title.trim(),
|
||||
commands,
|
||||
ne_ids: selectedIds,
|
||||
}),
|
||||
ne_ids,
|
||||
ume_ne_ids,
|
||||
});
|
||||
},
|
||||
onSuccess: async (job) => {
|
||||
showOk(t("collect.created", { id: job.id }));
|
||||
setCreateOpen(false);
|
||||
|
|
@ -157,7 +170,8 @@ export function CollectPage() {
|
|||
});
|
||||
|
||||
const addNe = (row: EligibleNeItem) => {
|
||||
setSelectedMap((prev) => (prev[row.id] ? prev : { ...prev, [row.id]: row }));
|
||||
const key = eligibleKey(row);
|
||||
setSelectedMap((prev) => (prev[key] ? prev : { ...prev, [key]: row }));
|
||||
};
|
||||
|
||||
const addBatchNe = (rows: EligibleNeItem[]) => {
|
||||
|
|
@ -165,16 +179,17 @@ export function CollectPage() {
|
|||
setSelectedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const row of rows) {
|
||||
if (!next[row.id]) next[row.id] = row;
|
||||
const key = eligibleKey(row);
|
||||
if (!next[key]) next[key] = row;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeNe = (id: string) => {
|
||||
const removeNe = (key: string) => {
|
||||
setSelectedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
|
@ -182,8 +197,10 @@ export function CollectPage() {
|
|||
const clearSelected = () => setSelectedMap({});
|
||||
|
||||
const eligibleItems = eligibleQuery.data?.items ?? [];
|
||||
const selectablePickItems = eligibleItems.filter((row) => !selectedMap[row.id]);
|
||||
const allPickSelected = selectablePickItems.length > 0 && selectablePickItems.every((row) => pickSelectedIds.includes(row.id));
|
||||
const selectablePickItems = eligibleItems.filter((row) => !selectedMap[eligibleKey(row)]);
|
||||
const allPickSelected =
|
||||
selectablePickItems.length > 0 &&
|
||||
selectablePickItems.every((row) => pickSelectedIds.includes(eligibleKey(row)));
|
||||
const batchPickCount = pickSelectedIds.length;
|
||||
const neTotal = eligibleQuery.data?.total ?? 0;
|
||||
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
|
||||
|
|
@ -257,20 +274,25 @@ export function CollectPage() {
|
|||
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
|
||||
) : (
|
||||
<div className="collect-selected-list">
|
||||
{selectedList.map((row) => (
|
||||
<div key={row.id} className="collect-selected-chip">
|
||||
{selectedList.map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const src = String(row.source || "managed").toLowerCase();
|
||||
return (
|
||||
<div key={key} className="collect-selected-chip">
|
||||
<span className="collect-selected-chip__main">
|
||||
<strong>{row.name || row.ip_address}</strong>
|
||||
<span className="collect-selected-chip__meta">
|
||||
{row.ip_address}
|
||||
{src}
|
||||
{row.ip_address ? ` · ${row.ip_address}` : ""}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="link-btn" onClick={() => removeNe(row.id)}>
|
||||
<button type="button" className="link-btn" onClick={() => removeNe(key)}>
|
||||
{t("collect.create.remove")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -287,7 +309,9 @@ export function CollectPage() {
|
|||
className="link-btn"
|
||||
disabled={batchPickCount === 0}
|
||||
onClick={() => {
|
||||
const rows = eligibleItems.filter((x) => pickSelectedIds.includes(x.id) && !selectedMap[x.id]);
|
||||
const rows = eligibleItems.filter(
|
||||
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
|
||||
);
|
||||
addBatchNe(rows);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
|
|
@ -327,7 +351,7 @@ export function CollectPage() {
|
|||
checked={allPickSelected}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setPickSelectedIds(selectablePickItems.map((row) => row.id));
|
||||
setPickSelectedIds(selectablePickItems.map((row) => eligibleKey(row)));
|
||||
return;
|
||||
}
|
||||
setPickSelectedIds([]);
|
||||
|
|
@ -335,6 +359,7 @@ export function CollectPage() {
|
|||
aria-label="pick all"
|
||||
/>
|
||||
</th>
|
||||
<th>{t("managedNe.col.source")}</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
|
|
@ -344,22 +369,27 @@ export function CollectPage() {
|
|||
</thead>
|
||||
<tbody>
|
||||
{eligibleItems.map((row) => {
|
||||
const picked = Boolean(selectedMap[row.id]);
|
||||
const key = eligibleKey(row);
|
||||
const picked = Boolean(selectedMap[key]);
|
||||
const src = String(row.source || "managed").toLowerCase();
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pickSelectedIds.includes(row.id)}
|
||||
checked={pickSelectedIds.includes(key)}
|
||||
disabled={picked}
|
||||
onChange={(e) => {
|
||||
if (picked) return;
|
||||
setPickSelectedIds((prev) =>
|
||||
e.target.checked ? [...new Set([...prev, row.id])] : prev.filter((id) => id !== row.id),
|
||||
e.target.checked ? [...new Set([...prev, key])] : prev.filter((id) => id !== key),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className="table-tag">{src}</span>
|
||||
</td>
|
||||
<td>{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
|
|
@ -373,7 +403,7 @@ export function CollectPage() {
|
|||
disabled={picked}
|
||||
onClick={() => {
|
||||
addNe(row);
|
||||
setPickSelectedIds((prev) => prev.filter((id) => id !== row.id));
|
||||
setPickSelectedIds((prev) => prev.filter((id) => id !== key));
|
||||
}}
|
||||
>
|
||||
{picked ? t("collect.create.added") : t("collect.create.add")}
|
||||
|
|
@ -703,6 +733,7 @@ function JobRunsPanel({
|
|||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("managedNe.col.source")}</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("collect.runs.status")}</th>
|
||||
|
|
@ -713,6 +744,9 @@ function JobRunsPanel({
|
|||
<tbody>
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id}>
|
||||
<td>
|
||||
<span className="table-tag">{run.ne_source || "managed"}</span>
|
||||
</td>
|
||||
<td>{run.ne_name}</td>
|
||||
<td>{run.ne_ip}</td>
|
||||
<td>{run.status}</td>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ import {
|
|||
type HopVendor,
|
||||
} from "../utils/hopProxy";
|
||||
|
||||
/** Hide UME→managed sync/delete controls until needed again. APIs remain available. */
|
||||
const SHOW_UME_MANAGED_SYNC = false;
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
vendor: string;
|
||||
|
|
@ -97,6 +100,13 @@ function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force
|
|||
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) };
|
||||
}
|
||||
|
||||
function managedSourceKey(source: string | undefined): "manual" | "ume_sync" | "webcrt" | "lldp" | "" {
|
||||
const s = String(source || "").trim().toLowerCase();
|
||||
if (!s) return "manual";
|
||||
if (s === "ume_sync" || s === "webcrt" || s === "lldp") return s;
|
||||
return "";
|
||||
}
|
||||
|
||||
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
||||
return (
|
||||
<span className="form-label">
|
||||
|
|
@ -571,24 +581,32 @@ export function NePage() {
|
|||
<button type="button" onClick={openCreate} disabled={!credsOk}>
|
||||
{t("managedNe.add")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => umeSyncMutation.mutate()}
|
||||
disabled={umeSyncMutation.isPending}
|
||||
>
|
||||
{umeSyncMutation.isPending ? t("managedNe.umeSync.syncing") : t("managedNe.umeSync.sync")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => {
|
||||
if (!window.confirm(t("managedNe.umeSync.deleteConfirm"))) return;
|
||||
umeDeleteMutation.mutate();
|
||||
}}
|
||||
disabled={umeDeleteMutation.isPending}
|
||||
>
|
||||
{umeDeleteMutation.isPending ? t("managedNe.umeSync.deleting") : t("managedNe.umeSync.delete")}
|
||||
</button>
|
||||
{SHOW_UME_MANAGED_SYNC ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => umeSyncMutation.mutate()}
|
||||
disabled={umeSyncMutation.isPending}
|
||||
>
|
||||
{umeSyncMutation.isPending
|
||||
? t("managedNe.umeSync.syncing")
|
||||
: t("managedNe.umeSync.sync")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => {
|
||||
if (!window.confirm(t("managedNe.umeSync.deleteConfirm"))) return;
|
||||
umeDeleteMutation.mutate();
|
||||
}}
|
||||
disabled={umeDeleteMutation.isPending}
|
||||
>
|
||||
{umeDeleteMutation.isPending
|
||||
? t("managedNe.umeSync.deleting")
|
||||
: t("managedNe.umeSync.delete")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -746,6 +764,7 @@ export function NePage() {
|
|||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.deviceType")}</th>
|
||||
<th>{t("managedNe.col.source")}</th>
|
||||
<th>{t("managedNe.col.tags")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("managedNe.col.user")}</th>
|
||||
|
|
@ -755,7 +774,12 @@ export function NePage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{listItems.map((row) => (
|
||||
{listItems.map((row) => {
|
||||
const srcKey = managedSourceKey(row.source);
|
||||
const srcLabel = srcKey
|
||||
? t(`managedNe.source.${srcKey}`)
|
||||
: String(row.source || "").trim() || t("managedNe.source.manual");
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<input
|
||||
|
|
@ -767,6 +791,9 @@ export function NePage() {
|
|||
<td className="pt-list-task-name">{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.device_type}</td>
|
||||
<td title={row.source_ref || undefined}>
|
||||
<span className="table-tag">{srcLabel}</span>
|
||||
</td>
|
||||
<td>
|
||||
{row.tags
|
||||
? row.tags.split(/\s+/).map((tag) => (
|
||||
|
|
@ -839,7 +866,8 @@ export function NePage() {
|
|||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3208,7 +3208,6 @@ export function WebcrtPage() {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="webcrt-new-session-title">{t("webcrt.newSession.title")}</h3>
|
||||
<p className="form-hint">{t("webcrt.newSession.hint")}</p>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<FormLabel required>{t("webcrt.newSession.protocol")}</FormLabel>
|
||||
|
|
|
|||
615
web/src/pages/network/TopologyClassifyPage.tsx
Normal file
615
web/src/pages/network/TopologyClassifyPage.tsx
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
bulkTagFabricNodes,
|
||||
fetchFabricNodes,
|
||||
fetchTopologyTree,
|
||||
generateTopologySlices,
|
||||
matchFabricNodes,
|
||||
patchFabricNodeTags,
|
||||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||
import type { FabricNodeSearchHit, SliceGenerateResult, TopologyTreeFolderItem } from "../../types";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
|
||||
if (!root) return [];
|
||||
return (root.children || []).filter((c) => String(c.kind) === "region");
|
||||
}
|
||||
|
||||
export function TopologyClassifyPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [filterRole, setFilterRole] = useState("");
|
||||
const [filterRegion, setFilterRegion] = useState("");
|
||||
const [unmatched, setUnmatched] = useState("");
|
||||
const [linkStatus, setLinkStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const linkLabel = (status: string | undefined) => {
|
||||
switch (String(status || "").toLowerCase()) {
|
||||
case "managed":
|
||||
return t("topoClassify.linkManaged");
|
||||
case "ume":
|
||||
return t("topoClassify.linkUme");
|
||||
case "both":
|
||||
return t("topoClassify.linkBoth");
|
||||
case "orphaned":
|
||||
return t("topoClassify.linkOrphaned");
|
||||
default:
|
||||
return status || "—";
|
||||
}
|
||||
};
|
||||
|
||||
const [regex, setRegex] = useState("");
|
||||
const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name");
|
||||
const [matchedIds, setMatchedIds] = useState<string[]>([]);
|
||||
const [matchTotal, setMatchTotal] = useState(0);
|
||||
const [assignRole, setAssignRole] = useState("core");
|
||||
const [assignRegion, setAssignRegion] = useState("");
|
||||
const [assignWhat, setAssignWhat] = useState<"role" | "region" | "both">("role");
|
||||
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
const [editingId, setEditingId] = useState("");
|
||||
const [editRole, setEditRole] = useState("");
|
||||
const [editRegion, setEditRegion] = useState("");
|
||||
|
||||
const [sliceFolderId, setSliceFolderId] = useState("");
|
||||
const [sliceTemplate, setSliceTemplate] = useState<"core_only" | "core_agg" | "agg_access">(
|
||||
"core_agg",
|
||||
);
|
||||
const [slicePreview, setSlicePreview] = useState<SliceGenerateResult | null>(null);
|
||||
const [seedPhysical, setSeedPhysical] = useState(true);
|
||||
const [showSlices, setShowSlices] = useState(false);
|
||||
|
||||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
});
|
||||
const regions = useMemo(() => flattenRegions(treeQuery.data?.root), [treeQuery.data?.root]);
|
||||
const regionName = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const r of regions) m[r.id] = r.name;
|
||||
return m;
|
||||
}, [regions]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.fabricNodeInventory(
|
||||
keyword,
|
||||
filterRole,
|
||||
filterRegion,
|
||||
unmatched,
|
||||
linkStatus,
|
||||
page,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchFabricNodes({
|
||||
keyword,
|
||||
role: filterRole,
|
||||
regionFolderId: filterRegion,
|
||||
unmatched,
|
||||
linkStatus,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
const invalidateList = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["fabricNodeInventory"] });
|
||||
};
|
||||
|
||||
const matchMut = useMutation({
|
||||
mutationFn: () =>
|
||||
matchFabricNodes({ pattern: regex, match_field: matchField, sample_limit: 80 }),
|
||||
onSuccess: (out) => {
|
||||
setMatchedIds(out.fabric_node_ids || []);
|
||||
setMatchTotal(out.total_matched || 0);
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const id of out.fabric_node_ids || []) next[id] = true;
|
||||
setSelected(next);
|
||||
showOk(t("topoClassify.matchOk").replace("{{count}}", String(out.total_matched)));
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const bulkMut = useMutation({
|
||||
mutationFn: () => {
|
||||
const ids = Object.keys(selected).filter((k) => selected[k]);
|
||||
const body: Parameters<typeof bulkTagFabricNodes>[0] = {
|
||||
fabric_node_ids: ids.length ? ids : matchedIds,
|
||||
dry_run: false,
|
||||
};
|
||||
if (assignWhat === "role" || assignWhat === "both") body.role = assignRole;
|
||||
if (assignWhat === "region" || assignWhat === "both") {
|
||||
body.region_folder_id = assignRegion || "";
|
||||
}
|
||||
return bulkTagFabricNodes(body);
|
||||
},
|
||||
onSuccess: async (out) => {
|
||||
showOk(t("topoClassify.bulkOk").replace("{{count}}", String(out.updated)));
|
||||
setMatchedIds([]);
|
||||
setMatchTotal(0);
|
||||
setSelected({});
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: () =>
|
||||
patchFabricNodeTags(editingId, {
|
||||
role: editRole,
|
||||
region_folder_id: editRegion || "",
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
showOk(t("topoClassify.rowSaved"));
|
||||
setEditingId("");
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const slicePreviewMut = useMutation({
|
||||
mutationFn: () =>
|
||||
generateTopologySlices({
|
||||
folder_id: sliceFolderId,
|
||||
template: sliceTemplate,
|
||||
dry_run: true,
|
||||
max_nodes: 300,
|
||||
seed_physical_cores: seedPhysical,
|
||||
}),
|
||||
onSuccess: (out) => setSlicePreview(out),
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const sliceApplyMut = useMutation({
|
||||
mutationFn: () =>
|
||||
generateTopologySlices({
|
||||
folder_id: sliceFolderId,
|
||||
template: sliceTemplate,
|
||||
dry_run: false,
|
||||
max_nodes: 300,
|
||||
seed_physical_cores: seedPhysical,
|
||||
}),
|
||||
onSuccess: async (out) => {
|
||||
showOk(t("topoClassify.sliceOk").replace("{{count}}", String(out.created_view_ids.length)));
|
||||
setSlicePreview(out);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items || [];
|
||||
const total = listQuery.data?.total || 0;
|
||||
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const selectedCount = Object.values(selected).filter(Boolean).length;
|
||||
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
|
||||
|
||||
const startEdit = (n: FabricNodeSearchHit) => {
|
||||
setEditingId(n.id);
|
||||
setEditRole(n.role || "");
|
||||
setEditRegion(n.region_folder_id || "");
|
||||
};
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
setSelected((s) => ({ ...s, [id]: !s[id] }));
|
||||
};
|
||||
|
||||
const togglePage = (on: boolean) => {
|
||||
setSelected((s) => {
|
||||
const next = { ...s };
|
||||
for (const n of items) next[n.id] = on;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel topo-classify">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("topoClassify.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => openOrFocusModule({ moduleId: "topology", path: "/topology" })}
|
||||
>
|
||||
{t("topoClassify.openTopo")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="topo-classify__section topo-classify__section--first">
|
||||
<h3>{t("topoClassify.inventory")}</h3>
|
||||
<div className="topo-classify__draft">
|
||||
<input
|
||||
className="input"
|
||||
placeholder={t("topoClassify.keywordPh")}
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={filterRole}
|
||||
onChange={(e) => {
|
||||
setFilterRole(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.filterRoleAll")}</option>
|
||||
<option value="core">core</option>
|
||||
<option value="aggregation">aggregation</option>
|
||||
<option value="access">access</option>
|
||||
<option value="unknown">unknown</option>
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
value={filterRegion}
|
||||
onChange={(e) => {
|
||||
setFilterRegion(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.filterRegionAll")}</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
value={unmatched}
|
||||
onChange={(e) => {
|
||||
setUnmatched(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.filterUnmatchedOff")}</option>
|
||||
<option value="any">{t("topoClassify.kindAny")}</option>
|
||||
<option value="role">{t("topoClassify.kindRole")}</option>
|
||||
<option value="region">{t("topoClassify.kindRegion")}</option>
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
value={linkStatus}
|
||||
onChange={(e) => {
|
||||
setLinkStatus(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.filterLinkAll")}</option>
|
||||
<option value="linked">{t("topoClassify.filterLinkLinked")}</option>
|
||||
<option value="orphaned">{t("topoClassify.filterLinkOrphaned")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="topo-classify__regex-bar">
|
||||
<strong>{t("topoClassify.regexTitle")}</strong>
|
||||
<input
|
||||
className="input topo-classify__regex-input"
|
||||
placeholder={t("topoClassify.patternPh")}
|
||||
value={regex}
|
||||
onChange={(e) => setRegex(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={matchField}
|
||||
onChange={(e) => setMatchField(e.target.value as "name" | "ip" | "name_ip")}
|
||||
>
|
||||
<option value="name">{t("topoClassify.fieldName")}</option>
|
||||
<option value="ip">IP</option>
|
||||
<option value="name_ip">{t("topoClassify.fieldNameIp")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={!regex.trim() || matchMut.isPending}
|
||||
onClick={() => matchMut.mutate()}
|
||||
>
|
||||
{t("topoClassify.findMatches")}
|
||||
</button>
|
||||
{matchTotal > 0 ? (
|
||||
<span className="panel__hint">
|
||||
{t("topoClassify.matchHint").replace("{{count}}", String(matchTotal))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="topo-classify__assign-bar">
|
||||
<select
|
||||
className="input"
|
||||
value={assignWhat}
|
||||
onChange={(e) => setAssignWhat(e.target.value as "role" | "region" | "both")}
|
||||
>
|
||||
<option value="role">{t("topoClassify.assignRole")}</option>
|
||||
<option value="region">{t("topoClassify.assignRegion")}</option>
|
||||
<option value="both">{t("topoClassify.assignBoth")}</option>
|
||||
</select>
|
||||
{assignWhat !== "region" ? (
|
||||
<select
|
||||
className="input"
|
||||
value={assignRole}
|
||||
onChange={(e) => setAssignRole(e.target.value)}
|
||||
>
|
||||
<option value="core">core</option>
|
||||
<option value="aggregation">aggregation</option>
|
||||
<option value="access">access</option>
|
||||
<option value="unknown">unknown</option>
|
||||
</select>
|
||||
) : null}
|
||||
{assignWhat !== "role" ? (
|
||||
<select
|
||||
className="input"
|
||||
value={assignRegion}
|
||||
onChange={(e) => setAssignRegion(e.target.value)}
|
||||
>
|
||||
<option value="">{t("topoClassify.clearRegion")}</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)}
|
||||
onClick={() => {
|
||||
const n = selectedCount || matchedIds.length;
|
||||
const msg = t("topoClassify.bulkConfirm").replace("{{count}}", String(n));
|
||||
if (window.confirm(msg)) bulkMut.mutate();
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.confirmAssign")}
|
||||
</button>
|
||||
<span className="panel__hint">
|
||||
{t("topoClassify.selectedCount").replace("{{count}}", String(selectedCount))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table className="data-table topo-classify__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={items.length > 0 && items.every((n) => selected[n.id])}
|
||||
onChange={(e) => togglePage(e.target.checked)}
|
||||
aria-label={t("topoClassify.selectPage")}
|
||||
/>
|
||||
</th>
|
||||
<th>{t("topoClassify.colNe")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("topoClassify.colLink")}</th>
|
||||
<th>{t("topoClassify.colRole")}</th>
|
||||
<th>{t("topoClassify.colRegion")}</th>
|
||||
<th>{t("topoClassify.colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((n) => {
|
||||
const editing = editingId === n.id;
|
||||
return (
|
||||
<tr
|
||||
key={n.id}
|
||||
className={highlight.has(n.id) || selected[n.id] ? "is-match" : undefined}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(selected[n.id])}
|
||||
onChange={() => toggleRow(n.id)}
|
||||
/>
|
||||
</td>
|
||||
<td>{n.name || n.id}</td>
|
||||
<td>{n.ip}</td>
|
||||
<td>
|
||||
{linkLabel(n.link_status)}
|
||||
{n.managed_source ? ` · ${n.managed_source}` : ""}
|
||||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<select
|
||||
className="input"
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
>
|
||||
<option value="">-</option>
|
||||
<option value="core">core</option>
|
||||
<option value="aggregation">aggregation</option>
|
||||
<option value="access">access</option>
|
||||
<option value="unknown">unknown</option>
|
||||
</select>
|
||||
) : (
|
||||
n.role || "-"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<select
|
||||
className="input"
|
||||
value={editRegion}
|
||||
onChange={(e) => setEditRegion(e.target.value)}
|
||||
>
|
||||
<option value="">-</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
(n.region_folder_id && regionName[n.region_folder_id]) ||
|
||||
n.region_folder_id ||
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<div className="topo-classify__row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={patchMut.isPending}
|
||||
onClick={() => patchMut.mutate()}
|
||||
>
|
||||
{t("topoClassify.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setEditingId("")}
|
||||
>
|
||||
{t("topoClassify.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => startEdit(n)}
|
||||
>
|
||||
{t("topoClassify.edit")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="panel__hint">
|
||||
{listQuery.isLoading ? t("topoClassify.loading") : t("topoClassify.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="topo-classify__pager">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span>
|
||||
{t("common.pagerMeta")
|
||||
.replace("{{total}}", String(total))
|
||||
.replace("{{page}}", String(page))
|
||||
.replace("{{pages}}", String(pages))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="topo-classify__section">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setShowSlices((v) => !v)}
|
||||
>
|
||||
{showSlices ? t("topoClassify.hideSlices") : t("topoClassify.showSlices")}
|
||||
</button>
|
||||
{showSlices ? (
|
||||
<>
|
||||
<h3>{t("topoClassify.slices")}</h3>
|
||||
<div className="topo-classify__draft">
|
||||
<select
|
||||
className="input"
|
||||
value={sliceFolderId}
|
||||
onChange={(e) => {
|
||||
setSliceFolderId(e.target.value);
|
||||
setSlicePreview(null);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.pickRegion")}</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
value={sliceTemplate}
|
||||
onChange={(e) =>
|
||||
setSliceTemplate(e.target.value as "core_only" | "core_agg" | "agg_access")
|
||||
}
|
||||
>
|
||||
<option value="core_only">{t("topoClassify.tplCore")}</option>
|
||||
<option value="core_agg">{t("topoClassify.tplCoreAgg")}</option>
|
||||
<option value="agg_access">{t("topoClassify.tplAggAcc")}</option>
|
||||
</select>
|
||||
<label className="topo-classify__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seedPhysical}
|
||||
onChange={(e) => setSeedPhysical(e.target.checked)}
|
||||
/>
|
||||
{t("topoClassify.seedPhysical")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={!sliceFolderId || slicePreviewMut.isPending}
|
||||
onClick={() => slicePreviewMut.mutate()}
|
||||
>
|
||||
{t("topoClassify.slicePreview")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={!sliceFolderId || sliceApplyMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("topoClassify.sliceConfirm"))) sliceApplyMut.mutate();
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.sliceApply")}
|
||||
</button>
|
||||
</div>
|
||||
{slicePreview ? (
|
||||
<div className="topo-classify__preview">
|
||||
<p className="panel__hint">
|
||||
{t("topoClassify.sliceStats")
|
||||
.replace("{{maps}}", String(slicePreview.map_count))
|
||||
.replace("{{overlap}}", String(slicePreview.overlap_node_count))}
|
||||
</p>
|
||||
<ul className="topo-classify__slice-list">
|
||||
{slicePreview.maps.map((m) => (
|
||||
<li key={m.name}>
|
||||
<strong>{m.name}</strong>
|
||||
{" · "}
|
||||
{m.role}
|
||||
{" · "}
|
||||
{t("topoClassify.nodeCount").replace("{{count}}", String(m.node_count))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ import type {
|
|||
FabricEdge,
|
||||
FabricSummary,
|
||||
TopologyDiscoverJob,
|
||||
TopologyOutsidePeer,
|
||||
TopologyTree,
|
||||
TopologyViewGraph,
|
||||
TopologyViewItem,
|
||||
LldpCollectDashboard,
|
||||
|
|
@ -442,7 +444,12 @@ export const fetchEligibleNe = (params: { page: number; pageSize: number; keywor
|
|||
);
|
||||
};
|
||||
|
||||
export const createNeCollection = (body: { title?: string; commands: string; ne_ids: string[] }) =>
|
||||
export const createNeCollection = (body: {
|
||||
title?: string;
|
||||
commands: string;
|
||||
ne_ids?: string[];
|
||||
ume_ne_ids?: string[];
|
||||
}) =>
|
||||
apiPost<CollectionJobItem>("/v1/ne-collections", body);
|
||||
|
||||
export const fetchNeCollections = (params: { page: number; pageSize: number }) => {
|
||||
|
|
@ -852,20 +859,81 @@ export const fetchUmeCurrentAlarms = (params: {
|
|||
export const fetchTopologyViews = () =>
|
||||
apiGet<{ total: number; items: TopologyViewItem[] }>("/v1/topology/views");
|
||||
|
||||
export const createTopologyView = (body: { name: string; remark?: string; filter?: Record<string, unknown> }) =>
|
||||
apiPost<TopologyViewItem>("/v1/topology/views", body);
|
||||
export const fetchTopologyTree = () => apiGet<TopologyTree>("/v1/topology/tree");
|
||||
|
||||
export const createTopologyFolder = (body: { name: string; kind?: string; parent_id?: string; sort_order?: number }) =>
|
||||
apiPost<{
|
||||
id: string;
|
||||
parent_id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_system: boolean;
|
||||
}>("/v1/topology/folders", body);
|
||||
|
||||
export const updateTopologyFolder = (
|
||||
folderId: string,
|
||||
body: { name?: string; parent_id?: string; sort_order?: number },
|
||||
) =>
|
||||
apiPatch<{
|
||||
id: string;
|
||||
parent_id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_system: boolean;
|
||||
}>(`/v1/topology/folders/${encodeURIComponent(folderId)}`, body);
|
||||
|
||||
export const deleteTopologyFolder = (folderId: string, force = false) =>
|
||||
apiDelete<{ ok: boolean; folder_id: string; deleted: boolean }>(
|
||||
`/v1/topology/folders/${encodeURIComponent(folderId)}?force=${force ? "true" : "false"}`,
|
||||
);
|
||||
|
||||
export const createTopologyView = (body: {
|
||||
name: string;
|
||||
remark?: string;
|
||||
filter?: Record<string, unknown>;
|
||||
folder_id?: string;
|
||||
kind?: string;
|
||||
role?: string;
|
||||
sort_order?: number;
|
||||
}) => apiPost<TopologyViewItem>("/v1/topology/views", body);
|
||||
|
||||
export const populateTopologyView = (
|
||||
viewId: string,
|
||||
body?: { dry_run?: boolean; membership?: Record<string, unknown>; freeze_after?: boolean },
|
||||
) =>
|
||||
apiPost<{
|
||||
view_id: string;
|
||||
dry_run: boolean;
|
||||
candidate_count: number;
|
||||
would_add: number;
|
||||
added: number;
|
||||
max_nodes: number;
|
||||
truncated: boolean;
|
||||
outside_peers: TopologyOutsidePeer[];
|
||||
graph: TopologyViewGraph | null;
|
||||
}>(`/v1/topology/views/${encodeURIComponent(viewId)}/populate`, body || {});
|
||||
|
||||
export const fetchTopologyViewGraph = (viewId: string) =>
|
||||
apiGet<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}`);
|
||||
|
||||
export const updateTopologyView = (
|
||||
viewId: string,
|
||||
body: { name?: string; remark?: string; filter?: Record<string, unknown>; viewport?: Record<string, unknown> },
|
||||
body: {
|
||||
name?: string;
|
||||
remark?: string;
|
||||
filter?: Record<string, unknown>;
|
||||
viewport?: Record<string, unknown>;
|
||||
folder_id?: string;
|
||||
role?: string;
|
||||
sort_order?: number;
|
||||
},
|
||||
) => apiPatch<TopologyViewItem>(`/v1/topology/views/${encodeURIComponent(viewId)}`, body);
|
||||
|
||||
export const deleteTopologyView = (viewId: string) =>
|
||||
export const deleteTopologyView = (viewId: string, force = false) =>
|
||||
apiDelete<{ ok: boolean; view_id: string; deleted: boolean }>(
|
||||
`/v1/topology/views/${encodeURIComponent(viewId)}`,
|
||||
`/v1/topology/views/${encodeURIComponent(viewId)}?force=${force ? "true" : "false"}`,
|
||||
);
|
||||
|
||||
export const patchTopologyPositions = (
|
||||
|
|
@ -908,6 +976,91 @@ export const patchTopologyEdgeStyle = (
|
|||
|
||||
export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabric/summary");
|
||||
|
||||
export const fetchFabricNodes = (params?: {
|
||||
keyword?: string;
|
||||
role?: string;
|
||||
regionFolderId?: string;
|
||||
unmatched?: string;
|
||||
linkStatus?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (params?.keyword) p.set("keyword", params.keyword);
|
||||
if (params?.role) p.set("role", params.role);
|
||||
if (params?.regionFolderId) p.set("region_folder_id", params.regionFolderId);
|
||||
if (params?.unmatched) p.set("unmatched", params.unmatched);
|
||||
if (params?.linkStatus) p.set("link_status", params.linkStatus);
|
||||
p.set("page", String(Math.max(1, Number(params?.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(500, Number(params?.pageSize || 50)))));
|
||||
return apiGet<{
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: import("../types").FabricNodeSearchHit[];
|
||||
}>(`/v1/topology/fabric/nodes?${p.toString()}`);
|
||||
};
|
||||
|
||||
export const matchFabricNodes = (body: {
|
||||
pattern: string;
|
||||
match_field?: string;
|
||||
sample_limit?: number;
|
||||
}) =>
|
||||
apiPost<{
|
||||
pattern: string;
|
||||
match_field: string;
|
||||
total_matched: number;
|
||||
samples: Array<Record<string, string>>;
|
||||
fabric_node_ids: string[];
|
||||
}>("/v1/topology/fabric/nodes/match", body);
|
||||
|
||||
export const bulkTagFabricNodes = (body: {
|
||||
fabric_node_ids?: string[];
|
||||
pattern?: string;
|
||||
match_field?: string;
|
||||
role?: string | null;
|
||||
region_folder_id?: string | null;
|
||||
dry_run?: boolean;
|
||||
}) =>
|
||||
apiPost<{
|
||||
dry_run: boolean;
|
||||
matched: number;
|
||||
updated: number;
|
||||
role?: string | null;
|
||||
region_folder_id?: string | null;
|
||||
samples: Array<Record<string, string>>;
|
||||
}>("/v1/topology/fabric/nodes/tags/bulk", body);
|
||||
|
||||
export const patchFabricNodeTags = (
|
||||
fabricNodeId: string,
|
||||
body: { role?: string | null; region_folder_id?: string | null },
|
||||
) =>
|
||||
apiPatch<import("../types").FabricNodeSearchHit>(
|
||||
`/v1/topology/fabric/nodes/${encodeURIComponent(fabricNodeId)}/tags`,
|
||||
body,
|
||||
);
|
||||
|
||||
export const generateTopologySlices = (body: {
|
||||
folder_id: string;
|
||||
template: "core_only" | "core_agg" | "agg_access";
|
||||
dry_run?: boolean;
|
||||
max_nodes?: number;
|
||||
seed_physical_cores?: boolean;
|
||||
}) => apiPost<import("../types").SliceGenerateResult>("/v1/topology/slices/generate", body);
|
||||
|
||||
export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (params?.q) p.set("q", params.q);
|
||||
p.set("page", String(Math.max(1, Number(params?.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(200, Number(params?.pageSize || 50)))));
|
||||
return apiGet<{
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: import("../types").FabricNodeSearchHit[];
|
||||
}>(`/v1/topology/fabric/nodes/search?${p.toString()}`);
|
||||
};
|
||||
|
||||
export const fetchFabricEdges = (params?: {
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
|
|
|
|||
119
web/src/types.ts
119
web/src/types.ts
|
|
@ -188,6 +188,8 @@ export type ManagedNeItem = {
|
|||
connect_tested_at: string | null;
|
||||
tags: string;
|
||||
remark: string;
|
||||
source?: string;
|
||||
source_ref?: string;
|
||||
hop_enabled: boolean;
|
||||
hop_vendor: string;
|
||||
hop_host: string;
|
||||
|
|
@ -287,6 +289,7 @@ export type UmeCliOverrideItem = {
|
|||
|
||||
export type EligibleNeItem = {
|
||||
id: string;
|
||||
source?: "managed" | "ume" | string;
|
||||
name: string;
|
||||
vendor: string;
|
||||
device_type: string;
|
||||
|
|
@ -315,6 +318,7 @@ export type CollectionRunItem = {
|
|||
id: string;
|
||||
job_id: string;
|
||||
ne_id: string;
|
||||
ne_source?: string;
|
||||
ne_name: string;
|
||||
ne_ip: string;
|
||||
status: string;
|
||||
|
|
@ -348,17 +352,124 @@ export type UmeTokenStatus = {
|
|||
error?: string;
|
||||
};
|
||||
|
||||
export type TopologyViewRole = "core" | "aggregation" | "access";
|
||||
export type TopologyViewKind = "physical" | "custom";
|
||||
|
||||
export type TopologyViewItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
remark: string;
|
||||
folder_id?: string;
|
||||
kind?: TopologyViewKind | string;
|
||||
role?: TopologyViewRole | string;
|
||||
sort_order?: number;
|
||||
filter?: Record<string, unknown>;
|
||||
viewport?: Record<string, unknown>;
|
||||
node_count: number;
|
||||
edge_count?: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TopologyTreeViewItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: TopologyViewKind | string;
|
||||
role?: TopologyViewRole | string;
|
||||
sort_order: number;
|
||||
node_count: number;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TopologyTreeFolderItem = {
|
||||
id: string;
|
||||
parent_id: string;
|
||||
kind: "root" | "region" | string;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_system: boolean;
|
||||
views: TopologyTreeViewItem[];
|
||||
children: TopologyTreeFolderItem[];
|
||||
};
|
||||
|
||||
export type TopologyTree = {
|
||||
root: TopologyTreeFolderItem | null;
|
||||
};
|
||||
|
||||
export type ClassifyRuleScope = "role" | "region";
|
||||
|
||||
export type ClassifyRule = {
|
||||
id: string;
|
||||
scope: ClassifyRuleScope | string;
|
||||
name: string;
|
||||
pattern: string;
|
||||
match_field: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
payload: Record<string, unknown>;
|
||||
remark?: string;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type ClassifyPreview = {
|
||||
total_nodes: number;
|
||||
role_matched: number;
|
||||
role_unmatched: number;
|
||||
role_conflicts: number;
|
||||
region_matched: number;
|
||||
region_unmatched: number;
|
||||
region_conflicts: number;
|
||||
role_samples: Array<Record<string, unknown>>;
|
||||
region_samples: Array<Record<string, unknown>>;
|
||||
unmatched_samples: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type ClassifyApplyResult = {
|
||||
role_updated: number;
|
||||
region_updated: number;
|
||||
skipped_manual: number;
|
||||
total_nodes: number;
|
||||
};
|
||||
|
||||
export type FabricNodeSearchHit = {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
vendor: string;
|
||||
managed_ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
role?: string;
|
||||
region_folder_id?: string | null;
|
||||
link_status?: "managed" | "ume" | "both" | "orphaned" | string;
|
||||
managed_alive?: boolean;
|
||||
ume_alive?: boolean;
|
||||
managed_source?: string;
|
||||
views?: Array<{
|
||||
view_id: string;
|
||||
view_name: string;
|
||||
folder_id: string;
|
||||
folder_name: string;
|
||||
kind: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SliceGenerateResult = {
|
||||
folder_id: string;
|
||||
template: string;
|
||||
dry_run: boolean;
|
||||
map_count: number;
|
||||
overlap_node_count: number;
|
||||
created_view_ids: string[];
|
||||
maps: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
node_count: number;
|
||||
seed_fabric_node_ids: string[];
|
||||
member_fabric_node_ids: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TopologyViewNodeItem = {
|
||||
fabric_node_id: string;
|
||||
managed_ne_id: string;
|
||||
|
|
@ -389,12 +500,20 @@ export type TopologyViewEdgeItem = {
|
|||
discovered_at?: string | null;
|
||||
};
|
||||
|
||||
export type TopologyOutsidePeer = {
|
||||
fabric_node_id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
via_node_id: string;
|
||||
};
|
||||
|
||||
export type TopologyViewGraph = {
|
||||
view: TopologyViewItem;
|
||||
nodes: TopologyViewNodeItem[];
|
||||
edges: TopologyViewEdgeItem[];
|
||||
truncated?: boolean;
|
||||
truncate_reason?: string;
|
||||
outside_peers?: TopologyOutsidePeer[];
|
||||
};
|
||||
|
||||
export type FabricSummary = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue