Industrialize topology editor and add customizable edge styles.

Add tool modes, layouts, and per-edge/default style overrides with a cleaner toolbar and context menus.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 12:31:06 +08:00
parent e4a365fa16
commit 99fce837f0
17 changed files with 1902 additions and 269 deletions

View file

@ -1052,6 +1052,15 @@ def on_startup() -> None:
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.loopback IS '业务环回IP(IPv4)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.device_level IS '网元层次'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.host_name IS '主机名称'")
conn.exec_driver_sql(
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_color VARCHAR(32) DEFAULT ''"
)
conn.exec_driver_sql(
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_width INTEGER DEFAULT 0"
)
conn.exec_driver_sql(
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS line_style VARCHAR(16) DEFAULT ''"
)
except Exception:
pass
try:

View file

@ -432,6 +432,11 @@ class TopologyEdge(Base):
target_port: Mapped[str] = mapped_column(String(128), default="")
# manual | lldp | cdp | stale
source: Mapped[str] = mapped_column(String(32), default="manual", index=True)
# Optional visual overrides; empty / 0 = use provenance defaults.
stroke_color: Mapped[str] = mapped_column(String(32), default="")
stroke_width: Mapped[int] = mapped_column(Integer, default=0)
# "" | solid | dashed | dotted
line_style: Mapped[str] = mapped_column(String(16), default="")
discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View file

@ -43,6 +43,9 @@ class TopologyEdgeIn(BaseModel):
source_port: str = ""
target_port: str = ""
source: str = "manual"
stroke_color: str = Field(default="", max_length=32)
stroke_width: int = Field(default=0, ge=0, le=12)
line_style: str = Field(default="", max_length=16)
class TopologyNodeOut(BaseModel):
@ -68,6 +71,9 @@ class TopologyEdgeOut(BaseModel):
source_port: str = ""
target_port: str = ""
source: str = "manual"
stroke_color: str = ""
stroke_width: int = 0
line_style: str = ""
discovered_at: datetime | None = None

View file

@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import uuid4
import re
from fastapi import HTTPException
from sqlalchemy.orm import Session
@ -165,6 +166,31 @@ def _node_out(
)
_HEX_COLOR_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
_LINE_STYLES = {"", "solid", "dashed", "dotted"}
def _normalize_edge_style(
*,
stroke_color: str = "",
stroke_width: int = 0,
line_style: str = "",
) -> tuple[str, int, str]:
color = str(stroke_color or "").strip()
if color and not _HEX_COLOR_RE.match(color):
raise HTTPException(status_code=400, detail="invalid_stroke_color")
try:
width = int(stroke_width or 0)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="invalid_stroke_width") from exc
if width < 0 or width > 12:
raise HTTPException(status_code=400, detail="invalid_stroke_width")
style = str(line_style or "").strip().lower()
if style not in _LINE_STYLES:
raise HTTPException(status_code=400, detail="invalid_line_style")
return color, width, style
def _edge_out(e: TopologyEdge) -> TopologyEdgeOut:
return TopologyEdgeOut(
id=e.id,
@ -174,6 +200,9 @@ def _edge_out(e: TopologyEdge) -> TopologyEdgeOut:
source_port=e.source_port or "",
target_port=e.target_port or "",
source=e.source or "manual",
stroke_color=getattr(e, "stroke_color", None) or "",
stroke_width=int(getattr(e, "stroke_width", 0) or 0),
line_style=getattr(e, "line_style", None) or "",
discovered_at=e.discovered_at,
)
@ -216,6 +245,7 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
raise HTTPException(status_code=400, detail=f"duplicate_node_id:{nid}")
node_ids.add(nid)
normalized_edges: list[tuple[TopologyEdgeIn, str, str, int, str]] = []
for e in edges_in:
sid = str(e.source_node_id or "").strip()
tid = str(e.target_node_id or "").strip()
@ -224,6 +254,12 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
src = str(e.source or "manual").strip().lower() or "manual"
if src not in {"manual", "lldp", "cdp", "stale"}:
raise HTTPException(status_code=400, detail="invalid_edge_source")
color, width, line = _normalize_edge_style(
stroke_color=getattr(e, "stroke_color", "") or "",
stroke_width=int(getattr(e, "stroke_width", 0) or 0),
line_style=getattr(e, "line_style", "") or "",
)
normalized_edges.append((e, src, color, width, line))
now = _utcnow()
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
@ -243,8 +279,7 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
updated_at=now,
)
)
for e in edges_in:
src = str(e.source or "manual").strip().lower() or "manual"
for e, src, color, width, line in normalized_edges:
db.add(
TopologyEdge(
id=str(e.id).strip() or uuid4().hex,
@ -254,6 +289,9 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
source_port=str(e.source_port or "").strip()[:128],
target_port=str(e.target_port or "").strip()[:128],
source=src,
stroke_color=color,
stroke_width=width,
line_style=line,
discovered_at=now if src in {"lldp", "cdp", "stale"} else None,
created_at=now,
updated_at=now,

View file

@ -262,6 +262,16 @@ class TopologyLldpParseTests(unittest.TestCase):
class TopologyServiceTests(unittest.TestCase):
def setUp(self) -> None:
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
conn.exec_driver_sql(
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_color VARCHAR(32) DEFAULT ''"
)
conn.exec_driver_sql(
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_width INTEGER DEFAULT 0"
)
conn.exec_driver_sql(
"ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS line_style VARCHAR(16) DEFAULT ''"
)
self.db = SessionLocal()
# Clean topology tables between tests
for m in self.db.query(TopologyMap).all():
@ -289,6 +299,9 @@ class TopologyServiceTests(unittest.TestCase):
source_node_id="n1",
target_node_id="n2",
source="manual",
stroke_color="#2563eb",
stroke_width=3,
line_style="dashed",
)
],
),
@ -296,6 +309,9 @@ class TopologyServiceTests(unittest.TestCase):
self.assertEqual(len(graph.nodes), 2)
self.assertEqual(len(graph.edges), 1)
self.assertEqual(graph.edges[0].source, "manual")
self.assertEqual(graph.edges[0].stroke_color, "#2563eb")
self.assertEqual(graph.edges[0].stroke_width, 3)
self.assertEqual(graph.edges[0].line_style, "dashed")
listed = svc.list_maps(self.db)
self.assertGreaterEqual(listed["total"], 1)

View file

@ -43,7 +43,7 @@ src/
| `/network/tasks/collect` | 采集任务 | `network` |
| `/network/tasks/config-sync` | 配置同步 | `network` |
| `/network/tasks/port-traffic` | 端口流量监控(任务向导 + 大屏) | `network` |
| `/topology` | 拓扑管理 | `topology` |
| `/topology` | 拓扑管理(模式化编辑器:选择/平移/拖动/连线、框选、自动布局、拖放添加) | `topology` |
| `/webcrt` | WebCRT 终端 | `webcrt` |
| `/collect` | redirect → `/network/tasks/collect` | — |
@ -117,6 +117,15 @@ src/
- 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口)
## 拓扑管理
- 渲染:`@xyflow/react`;布局:`dagre`(层次)+ 内置力导向/网格/环形
- 工具模式:选择(框选多选)/ 平移 / 拖动 / 连线;快捷键 `V` `H` `A` `C`
- 侧栏网元可点击或拖放到画布;发现链路后可选自动层次布局并写回坐标
- 支持对齐、网格吸附、轻量撤销/重做、链路图例
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式在右键菜单中调整
## WebCRT
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`

34
web/package-lock.json generated
View file

@ -12,6 +12,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.11.2",
"dagre": "^0.8.5",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
@ -19,6 +20,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/dagre": "^0.7.54",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@ -932,6 +934,13 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.54",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -1593,6 +1602,16 @@
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1981,6 +2000,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@ -2403,6 +2431,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",

View file

@ -17,6 +17,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.11.2",
"dagre": "^0.8.5",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
@ -24,6 +25,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/dagre": "^0.7.54",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

View file

@ -888,7 +888,7 @@ const en = {
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
emptyMaps: "No maps yet. Create one to start.",
palette: "NE library",
paletteHint: "Click to add to canvas",
paletteHint: "Click or drag onto the canvas",
filterPh: "Filter name / IP",
save: "Save",
saving: "Saving…",
@ -900,13 +900,56 @@ const en = {
discoverFail: "Discovery failed: {{detail}}",
discoverOneNeedNe: "This node is not linked to a scannable NE",
selectMap: "Select or create a topology map",
canvasHint: "Add NEs from the left; blue dashed=found, red dashed=missing (deletable); right-click a node for actions",
canvasHint:
"Use toolbar modes (select/pan/drag/connect), box-select, and layout. Blue dashed=discovered, red=missing. Shortcuts V/H/A/C, Ctrl+Z undo.",
selected: "Selected",
selectedEdge: "Selected edge",
selectedCount: "{{count}} selected",
toolModes: "Edit modes",
toolSelect: "Select",
toolPan: "Pan",
toolDrag: "Drag",
toolConnect: "Connect",
layout: "Layout",
layoutHierarchicalTb: "Hierarchical · top-bottom",
layoutHierarchicalLr: "Hierarchical · left-right",
layoutForce: "Force-directed",
layoutGrid: "Grid",
layoutRadial: "Radial",
layoutSelected: "Selected only (hierarchical)",
layoutNeedSelection: "Select nodes to lay out first",
align: "Align",
alignLeft: "Align left",
alignRight: "Align right",
alignTop: "Align top",
alignBottom: "Align bottom",
alignHCenter: "Align center X",
alignVCenter: "Align center Y",
alignHDistribute: "Distribute horizontally",
alignVDistribute: "Distribute vertically",
alignNeedSelection: "Select at least two nodes",
snapGrid: "Snap to grid",
autoLayoutDiscover: "Auto-layout after discover",
undo: "Undo",
redo: "Redo",
discoverSelected: "Discover selected ({{count}})",
removeSelected: "Remove selected ({{count}})",
openWebcrt: "Open terminal",
openNe: "NE details",
removeNode: "Remove from canvas",
removeEdge: "Delete edge",
nodeMenu: "Node",
selectionMenu: "Selection",
edgeStyle: "Edge style",
edgeColor: "Color",
edgeLineStyle: "Line",
edgeLineSolid: "Solid",
edgeLineDashed: "Dashed",
edgeLineDotted: "Dotted",
edgeWidth: "Width",
edgeStyleReset: "Reset style",
edgeDefaults: "Default edge styles",
edgeDefaultsReset: "Factory reset",
noNeLink: "Not linked to a managed NE",
edgeManual: "Manual",
edgeDiscovered: "Discovered",

View file

@ -885,7 +885,7 @@ const zh = {
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
emptyMaps: "还没有拓扑图,先新建一张。",
palette: "网元库",
paletteHint: "点击添加到画布",
paletteHint: "点击或拖到画布添加",
filterPh: "筛选名称 / IP",
save: "保存",
saving: "保存中…",
@ -897,13 +897,56 @@ const zh = {
discoverFail: "发现失败:{{detail}}",
discoverOneNeedNe: "该节点未关联可扫描网元",
selectMap: "请选择或新建一张拓扑图",
canvasHint: "从左侧添加网元,拖动节点并连线;蓝虚线=发现链路,红虚线=本次未发现(可删);右键节点可操作",
canvasHint:
"工具栏切换选择/平移/拖动/连线;框选多选;布局可自动构图。蓝虚线=发现链路,红虚线=未发现。快捷键 V/H/A/C,Ctrl+Z 撤销。",
selected: "已选节点",
selectedEdge: "已选链路",
selectedCount: "已选 {{count}}",
toolModes: "编辑模式",
toolSelect: "选择",
toolPan: "平移",
toolDrag: "拖动",
toolConnect: "连线",
layout: "布局",
layoutHierarchicalTb: "层次 · 自上而下",
layoutHierarchicalLr: "层次 · 从左到右",
layoutForce: "力导向",
layoutGrid: "网格",
layoutRadial: "环形",
layoutSelected: "仅布局选中(层次)",
layoutNeedSelection: "请先选中要布局的节点",
align: "对齐",
alignLeft: "左对齐",
alignRight: "右对齐",
alignTop: "顶对齐",
alignBottom: "底对齐",
alignHCenter: "水平居中",
alignVCenter: "垂直居中",
alignHDistribute: "水平分布",
alignVDistribute: "垂直分布",
alignNeedSelection: "请至少选中两个节点",
snapGrid: "网格吸附",
autoLayoutDiscover: "发现后自动布局",
undo: "撤销",
redo: "重做",
discoverSelected: "发现选中 ({{count}})",
removeSelected: "移除选中 ({{count}})",
openWebcrt: "打开终端",
openNe: "网元详情",
removeNode: "从画布移除",
removeEdge: "删除链路",
nodeMenu: "网元",
selectionMenu: "多选",
edgeStyle: "链路样式",
edgeColor: "颜色",
edgeLineStyle: "线型",
edgeLineSolid: "实线",
edgeLineDashed: "虚线",
edgeLineDotted: "点线",
edgeWidth: "粗细",
edgeStyleReset: "恢复默认",
edgeDefaults: "默认链路样式",
edgeDefaultsReset: "恢复出厂",
noNeLink: "未关联托管网元",
edgeManual: "人工",
edgeDiscovered: "发现",
@ -911,7 +954,7 @@ const zh = {
fit: "适应画布",
fullscreen: "全屏显示",
exitFullscreen: "退出全屏",
display: "显示选项",
display: "显示",
hideIp: "隐藏 IP",
hideVendor: "隐藏厂商",
hidePorts: "隐藏端口",

View file

@ -2232,22 +2232,37 @@ pre {
}
.topo-toolbar {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.topo-toolbar__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: nowrap;
min-width: 0;
}
.topo-toolbar__left {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
.topo-toolbar__row--tools {
justify-content: flex-start;
flex-wrap: wrap;
gap: 10px;
padding-top: 2px;
border-top: 1px solid #e2e8f0;
}
.topo-toolbar__group + .topo-toolbar__group,
.topo-tools + .topo-toolbar__group,
.topo-toolbar__group + .topo-toolbar__display,
.topo-tools + .topo-toolbar__display {
margin-left: 2px;
padding-left: 10px;
border-left: 1px solid #e2e8f0;
}
.topo-toolbar__title {
@ -2261,33 +2276,221 @@ pre {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.topo-toolbar__meta {
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
.topo-toolbar__actions {
display: flex;
flex-wrap: nowrap;
flex-wrap: wrap;
align-items: center;
gap: 6px;
justify-content: flex-end;
}
.topo-toolbar__group {
display: inline-flex;
align-items: center;
gap: 6px;
}
.topo-tools {
display: inline-flex;
gap: 2px;
padding: 2px;
background: #e2e8f0;
border-radius: 8px;
flex-shrink: 0;
}
.topo-tools__btn {
border: 0;
background: transparent;
color: #475569;
font-size: 12px;
font-weight: 600;
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.topo-tools__btn:hover {
background: #cbd5e1;
color: #0f172a;
}
.topo-tools__btn.is-active {
background: #0f172a;
color: #f8fafc;
}
.topo-toolbar__select {
min-width: 88px;
height: 30px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #0f172a;
font-size: 12px;
padding: 0 8px;
}
.topo-toolbar__display {
position: relative;
}
.topo-toolbar__display > summary {
list-style: none;
cursor: pointer;
user-select: none;
height: 30px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #0f172a;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
}
.topo-toolbar__display > summary::-webkit-details-marker {
display: none;
}
.topo-toolbar__display[open] > summary {
border-color: #94a3b8;
background: #f1f5f9;
}
.topo-toolbar__display .topo-display-toggles {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 20;
display: grid;
gap: 6px;
min-width: 260px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.topo-display-defaults {
display: grid;
gap: 8px;
flex: 0 0 auto;
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid #e2e8f0;
}
.topo-display-defaults__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #334155;
}
.topo-display-defaults__row {
display: grid;
grid-template-columns: 48px 28px minmax(72px, 1fr) 64px;
align-items: center;
gap: 6px;
}
.topo-display-defaults__name {
font-size: 12px;
color: #475569;
}
.topo-display-defaults__row input[type="color"] {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
.topo-display-defaults__row .topo-toolbar__select {
min-width: 0;
width: 100%;
}
.topo-canvas.is-pan-mode .react-flow__pane {
cursor: grab;
}
.topo-legend {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 5;
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
padding: 6px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e2e8f0;
font-size: 11px;
color: #475569;
pointer-events: none;
}
.topo-legend__item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.topo-legend__swatch {
display: inline-block;
width: 16px;
height: 0;
border-top: 2px solid #64748b;
vertical-align: middle;
}
.topo-node.is-connect-mode .topo-node__handle {
width: 12px !important;
height: 12px !important;
opacity: 1 !important;
background: #0ea5e9 !important;
border: 2px solid #fff !important;
}
.react-flow__selection {
background: rgba(37, 99, 235, 0.08);
border: 1px solid rgba(37, 99, 235, 0.55);
}
.topo-display-toggles {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 10px;
margin-right: 4px;
font-size: 12px;
color: #475569;
display: grid;
gap: 6px;
}
.topo-display-toggles__item {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 6px;
font-size: 12px;
color: #334155;
white-space: nowrap;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.topo-ctx {
@ -2302,6 +2505,90 @@ pre {
border: 1px solid #334155;
border-radius: 8px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28);
font-size: 12px;
line-height: 1.3;
}
.topo-ctx--edge {
min-width: 196px;
}
.topo-ctx__head {
margin: 0;
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: #94a3b8;
}
.topo-ctx__style {
padding: 4px 12px 6px;
}
.topo-ctx__style-title {
margin: 0 0 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: #94a3b8;
}
.topo-ctx__style-grid {
display: grid;
gap: 6px;
}
.topo-ctx__style-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 0;
font-size: 12px;
font-weight: 400;
color: #e2e8f0;
line-height: 1.2;
}
.topo-ctx__style-row > span {
flex: 0 0 auto;
min-width: 2.5em;
color: #cbd5e1;
}
.topo-ctx__style-row input[type="color"] {
flex: 0 0 auto;
box-sizing: border-box;
width: 28px;
height: 22px;
padding: 0;
border: 1px solid #475569;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.topo-ctx__style-row input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.topo-ctx__style-row input[type="color"]::-webkit-color-swatch {
border: 0;
border-radius: 2px;
}
.topo-ctx__style-row select {
flex: 1 1 auto;
max-width: 108px;
height: 24px;
border: 1px solid #475569;
border-radius: 4px;
background: #1e293b;
color: #e2e8f0;
font-size: 12px;
line-height: 1.2;
padding: 0 6px;
}
/* Beat .app-main button { color:#1f2937; background:#fff } so hover stays readable. */
@ -2309,13 +2596,16 @@ pre {
.topo-ctx__item {
display: block;
width: 100%;
padding: 8px 14px;
box-sizing: border-box;
padding: 7px 12px;
border: 0;
border-radius: 0;
background: transparent;
color: #e2e8f0;
text-align: left;
font: inherit;
font-size: 12px;
font-weight: 400;
line-height: 1.3;
cursor: pointer;
}
@ -2481,40 +2771,31 @@ pre {
font-size: 11px;
text-align: center;
/* Default asset is blue (= ZTE). */
--topo-vendor: #0277bd;
--topo-icon-filter: none;
}
.topo-node--cisco {
--topo-vendor: #1d4ed8;
--topo-icon-filter: hue-rotate(-8deg) saturate(1.15);
}
.topo-node--huawei {
--topo-vendor: #c62828;
--topo-icon-filter: hue-rotate(155deg) saturate(1.35);
}
.topo-node--zte {
--topo-vendor: #0277bd;
--topo-icon-filter: none;
}
.topo-node--juniper {
--topo-vendor: #2e7d32;
--topo-icon-filter: hue-rotate(95deg) saturate(1.2);
}
.topo-node--nokia {
--topo-vendor: #00838f;
--topo-icon-filter: hue-rotate(-28deg) saturate(1.1) brightness(0.98);
}
.topo-node--ericsson {
--topo-vendor: #f9a825;
--topo-icon-filter: hue-rotate(175deg) saturate(1.45) brightness(1.05);
}
.topo-node--h3c {
--topo-vendor: #ef6c00;
--topo-icon-filter: hue-rotate(165deg) saturate(1.4);
}
.topo-node--other {
--topo-vendor: #546e7a;
--topo-icon-filter: grayscale(0.85) brightness(0.95);
}
@ -2526,13 +2807,18 @@ pre {
}
.topo-node__handle {
width: 12px !important;
height: 12px !important;
min-width: 12px !important;
min-height: 12px !important;
width: 10px !important;
height: 10px !important;
min-width: 10px !important;
min-height: 10px !important;
background: transparent !important;
border: 0 !important;
opacity: 0;
opacity: 0 !important;
pointer-events: none;
}
.topo-node.is-connect-mode .topo-node__handle {
pointer-events: all;
}
.topo-node__handle--center {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
import type { Node } from "@xyflow/react";
export type AlignKind = "left" | "right" | "top" | "bottom" | "hDistribute" | "vDistribute";
export function alignNodes<T extends Record<string, unknown>>(
nodes: Node<T>[],
kind: AlignKind,
selectedIds: Set<string>,
): Node<T>[] {
const selected = nodes.filter((n) => selectedIds.has(n.id));
if (selected.length < 2) return nodes;
const xs = selected.map((n) => n.position.x);
const ys = selected.map((n) => n.position.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const byX = [...selected].sort((a, b) => a.position.x - b.position.x);
const byY = [...selected].sort((a, b) => a.position.y - b.position.y);
const next = new Map<string, { x: number; y: number }>();
if (kind === "left") {
for (const n of selected) next.set(n.id, { x: minX, y: n.position.y });
} else if (kind === "right") {
for (const n of selected) next.set(n.id, { x: maxX, y: n.position.y });
} else if (kind === "top") {
for (const n of selected) next.set(n.id, { x: n.position.x, y: minY });
} else if (kind === "bottom") {
for (const n of selected) next.set(n.id, { x: n.position.x, y: maxY });
} else if (kind === "hDistribute") {
if (byX.length === 2) {
next.set(byX[0].id, { ...byX[0].position });
next.set(byX[1].id, { ...byX[1].position });
} else {
const span = maxX - minX;
byX.forEach((n, i) => {
next.set(n.id, {
x: minX + (span * i) / (byX.length - 1),
y: n.position.y,
});
});
}
} else if (kind === "vDistribute") {
if (byY.length === 2) {
next.set(byY[0].id, { ...byY[0].position });
next.set(byY[1].id, { ...byY[1].position });
} else {
const span = maxY - minY;
byY.forEach((n, i) => {
next.set(n.id, {
x: n.position.x,
y: minY + (span * i) / (byY.length - 1),
});
});
}
}
return nodes.map((n) => {
const p = next.get(n.id);
return p ? { ...n, position: p } : n;
});
}

View file

@ -0,0 +1,234 @@
import dagre from "dagre";
import type { Edge, Node } from "@xyflow/react";
export type LayoutKind = "hierarchical-tb" | "hierarchical-lr" | "force" | "grid" | "radial";
const NODE_W = 140;
const NODE_H = 72;
type XY = { x: number; y: number };
function applyPositions<T extends Record<string, unknown>>(
nodes: Node<T>[],
positions: Map<string, XY>,
): Node<T>[] {
return nodes.map((n) => {
const p = positions.get(n.id);
if (!p) return n;
return { ...n, position: { x: p.x, y: p.y } };
});
}
function layoutDagre<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
rankdir: "TB" | "LR",
): Node<T>[] {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir, nodesep: 48, ranksep: 72, marginx: 24, marginy: 24 });
for (const n of nodes) {
g.setNode(n.id, { width: NODE_W, height: NODE_H });
}
for (const e of edges) {
if (g.hasNode(e.source) && g.hasNode(e.target)) {
g.setEdge(e.source, e.target);
}
}
dagre.layout(g);
const positions = new Map<string, XY>();
for (const n of nodes) {
const dn = g.node(n.id);
if (!dn) continue;
positions.set(n.id, { x: dn.x - NODE_W / 2, y: dn.y - NODE_H / 2 });
}
return applyPositions(nodes, positions);
}
/** Lightweight spring layout (no d3 dependency). */
function layoutForce<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
iterations = 120,
): Node<T>[] {
if (nodes.length === 0) return nodes;
const pos = new Map<string, XY>();
const vel = new Map<string, XY>();
const cx = 400;
const cy = 300;
nodes.forEach((n, i) => {
const angle = (2 * Math.PI * i) / nodes.length;
const r = 80 + nodes.length * 4;
pos.set(n.id, {
x: n.position?.x || cx + Math.cos(angle) * r,
y: n.position?.y || cy + Math.sin(angle) * r,
});
vel.set(n.id, { x: 0, y: 0 });
});
const ids = nodes.map((n) => n.id);
const links = edges
.filter((e) => pos.has(e.source) && pos.has(e.target))
.map((e) => ({ s: e.source, t: e.target }));
const repulsion = 2800;
const attraction = 0.04;
const ideal = 160;
const damping = 0.85;
for (let iter = 0; iter < iterations; iter++) {
for (const id of ids) {
const p = pos.get(id)!;
let fx = 0;
let fy = 0;
for (const other of ids) {
if (other === id) continue;
const q = pos.get(other)!;
let dx = p.x - q.x;
let dy = p.y - q.y;
let dist2 = dx * dx + dy * dy;
if (dist2 < 1) {
dx = Math.random() - 0.5;
dy = Math.random() - 0.5;
dist2 = dx * dx + dy * dy;
}
const dist = Math.sqrt(dist2);
const force = repulsion / dist2;
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
for (const link of links) {
if (link.s !== id && link.t !== id) continue;
const other = link.s === id ? link.t : link.s;
const q = pos.get(other)!;
const dx = q.x - p.x;
const dy = q.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - ideal) * attraction;
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
const v = vel.get(id)!;
v.x = (v.x + fx) * damping;
v.y = (v.y + fy) * damping;
p.x += v.x;
p.y += v.y;
}
}
return applyPositions(nodes, pos);
}
function layoutGrid<T extends Record<string, unknown>>(nodes: Node<T>[]): Node<T>[] {
const cols = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
const gapX = NODE_W + 40;
const gapY = NODE_H + 48;
return nodes.map((n, i) => ({
...n,
position: {
x: (i % cols) * gapX + 40,
y: Math.floor(i / cols) * gapY + 40,
},
}));
}
function layoutRadial<T extends Record<string, unknown>>(nodes: Node<T>[]): Node<T>[] {
if (nodes.length === 0) return nodes;
if (nodes.length === 1) {
return [{ ...nodes[0], position: { x: 320, y: 240 } }];
}
const cx = 400;
const cy = 300;
const r = 60 + nodes.length * 18;
return nodes.map((n, i) => {
const angle = (2 * Math.PI * i) / nodes.length - Math.PI / 2;
return {
...n,
position: {
x: cx + Math.cos(angle) * r - NODE_W / 2,
y: cy + Math.sin(angle) * r - NODE_H / 2,
},
};
});
}
export function layoutGraph<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
kind: LayoutKind,
opts?: { onlyIds?: Set<string> },
): Node<T>[] {
const only = opts?.onlyIds;
const targets = only && only.size > 0 ? nodes.filter((n) => only.has(n.id)) : nodes;
const targetIds = new Set(targets.map((n) => n.id));
const subEdges = edges.filter((e) => targetIds.has(e.source) && targetIds.has(e.target));
let laid: Node<T>[];
switch (kind) {
case "hierarchical-lr":
laid = layoutDagre(targets, subEdges, "LR");
break;
case "force":
laid = layoutForce(targets, subEdges);
break;
case "grid":
laid = layoutGrid(targets);
break;
case "radial":
laid = layoutRadial(targets);
break;
case "hierarchical-tb":
default:
laid = layoutDagre(targets, subEdges, "TB");
break;
}
if (!only || only.size === 0) return laid;
const byId = new Map(laid.map((n) => [n.id, n]));
return nodes.map((n) => byId.get(n.id) || n);
}
export function alignNodes<T extends Record<string, unknown>>(
nodes: Node<T>[],
ids: string[],
kind: "left" | "right" | "top" | "bottom" | "h-center" | "v-center" | "h-distribute" | "v-distribute",
): Node<T>[] {
const selected = nodes.filter((n) => ids.includes(n.id));
if (selected.length < 2) return nodes;
const xs = selected.map((n) => n.position.x);
const ys = selected.map((n) => n.position.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const pos = new Map<string, XY>();
if (kind === "h-distribute") {
const sorted = [...selected].sort((a, b) => a.position.x - b.position.x);
const span = maxX - minX;
sorted.forEach((n, i) => {
const x = sorted.length === 1 ? minX : minX + (span * i) / (sorted.length - 1);
pos.set(n.id, { x, y: n.position.y });
});
} else if (kind === "v-distribute") {
const sorted = [...selected].sort((a, b) => a.position.y - b.position.y);
const span = maxY - minY;
sorted.forEach((n, i) => {
const y = sorted.length === 1 ? minY : minY + (span * i) / (sorted.length - 1);
pos.set(n.id, { x: n.position.x, y });
});
} else {
for (const n of selected) {
let x = n.position.x;
let y = n.position.y;
if (kind === "left") x = minX;
if (kind === "right") x = maxX;
if (kind === "top") y = minY;
if (kind === "bottom") y = maxY;
if (kind === "h-center") x = midX;
if (kind === "v-center") y = midY;
pos.set(n.id, { x, y });
}
}
return applyPositions(nodes, pos);
}

View file

@ -0,0 +1,64 @@
/** Topology editor interaction modes (NMS-style). */
export type ToolMode = "select" | "pan" | "drag" | "connect";
export type ToolModeBehavior = {
nodesDraggable: boolean;
nodesConnectable: boolean;
elementsSelectable: boolean;
/** false = no pane pan on drag; true = left-drag pans; array = button indices */
panOnDrag: boolean | number[];
selectionOnDrag: boolean;
panOnScroll: boolean;
};
export function behaviorForMode(mode: ToolMode): ToolModeBehavior {
switch (mode) {
case "pan":
return {
nodesDraggable: false,
nodesConnectable: false,
elementsSelectable: false,
panOnDrag: true,
selectionOnDrag: false,
panOnScroll: true,
};
case "drag":
return {
nodesDraggable: true,
nodesConnectable: false,
elementsSelectable: true,
panOnDrag: [1, 2],
selectionOnDrag: false,
panOnScroll: true,
};
case "connect":
return {
nodesDraggable: false,
nodesConnectable: true,
elementsSelectable: true,
panOnDrag: [1, 2],
selectionOnDrag: false,
panOnScroll: true,
};
case "select":
default:
return {
nodesDraggable: true,
nodesConnectable: false,
elementsSelectable: true,
panOnDrag: [1, 2],
selectionOnDrag: true,
panOnScroll: true,
};
}
}
export function toolModeFromKey(key: string): ToolMode | null {
const k = key.toLowerCase();
if (k === "v") return "select";
if (k === "h") return "pan";
if (k === "a") return "drag";
if (k === "c") return "connect";
return null;
}

View file

@ -34,6 +34,7 @@ import type {
PortTrafficDiscoverResponse,
PortTrafficSamples,
PortTrafficTarget,
PortTrafficTargetIn,
PortTrafficTask,
} from "../types";
@ -568,6 +569,9 @@ export const putTopologyGraph = (
source_port?: string;
target_port?: string;
source?: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
}>;
},
) => apiPut<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body);

View file

@ -377,6 +377,9 @@ export type TopologyEdgeItem = {
source_port: string;
target_port: string;
source: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
discovered_at?: string | null;
};