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.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.device_level IS '网元层次'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.host_name 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: except Exception:
pass pass
try: try:

View file

@ -432,6 +432,11 @@ class TopologyEdge(Base):
target_port: Mapped[str] = mapped_column(String(128), default="") target_port: Mapped[str] = mapped_column(String(128), default="")
# manual | lldp | cdp | stale # manual | lldp | cdp | stale
source: Mapped[str] = mapped_column(String(32), default="manual", index=True) 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) discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_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 = "" source_port: str = ""
target_port: str = "" target_port: str = ""
source: str = "manual" 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): class TopologyNodeOut(BaseModel):
@ -68,6 +71,9 @@ class TopologyEdgeOut(BaseModel):
source_port: str = "" source_port: str = ""
target_port: str = "" target_port: str = ""
source: str = "manual" source: str = "manual"
stroke_color: str = ""
stroke_width: int = 0
line_style: str = ""
discovered_at: datetime | None = None discovered_at: datetime | None = None

View file

@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
import re
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session 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: def _edge_out(e: TopologyEdge) -> TopologyEdgeOut:
return TopologyEdgeOut( return TopologyEdgeOut(
id=e.id, id=e.id,
@ -174,6 +200,9 @@ def _edge_out(e: TopologyEdge) -> TopologyEdgeOut:
source_port=e.source_port or "", source_port=e.source_port or "",
target_port=e.target_port or "", target_port=e.target_port or "",
source=e.source or "manual", 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, 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}") raise HTTPException(status_code=400, detail=f"duplicate_node_id:{nid}")
node_ids.add(nid) node_ids.add(nid)
normalized_edges: list[tuple[TopologyEdgeIn, str, str, int, str]] = []
for e in edges_in: for e in edges_in:
sid = str(e.source_node_id or "").strip() sid = str(e.source_node_id or "").strip()
tid = str(e.target_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" src = str(e.source or "manual").strip().lower() or "manual"
if src not in {"manual", "lldp", "cdp", "stale"}: if src not in {"manual", "lldp", "cdp", "stale"}:
raise HTTPException(status_code=400, detail="invalid_edge_source") 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() now = _utcnow()
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False) 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, updated_at=now,
) )
) )
for e in edges_in: for e, src, color, width, line in normalized_edges:
src = str(e.source or "manual").strip().lower() or "manual"
db.add( db.add(
TopologyEdge( TopologyEdge(
id=str(e.id).strip() or uuid4().hex, 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], source_port=str(e.source_port or "").strip()[:128],
target_port=str(e.target_port or "").strip()[:128], target_port=str(e.target_port or "").strip()[:128],
source=src, source=src,
stroke_color=color,
stroke_width=width,
line_style=line,
discovered_at=now if src in {"lldp", "cdp", "stale"} else None, discovered_at=now if src in {"lldp", "cdp", "stale"} else None,
created_at=now, created_at=now,
updated_at=now, updated_at=now,

View file

@ -262,6 +262,16 @@ class TopologyLldpParseTests(unittest.TestCase):
class TopologyServiceTests(unittest.TestCase): class TopologyServiceTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
Base.metadata.create_all(bind=engine) 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() self.db = SessionLocal()
# Clean topology tables between tests # Clean topology tables between tests
for m in self.db.query(TopologyMap).all(): for m in self.db.query(TopologyMap).all():
@ -289,6 +299,9 @@ class TopologyServiceTests(unittest.TestCase):
source_node_id="n1", source_node_id="n1",
target_node_id="n2", target_node_id="n2",
source="manual", 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.nodes), 2)
self.assertEqual(len(graph.edges), 1) self.assertEqual(len(graph.edges), 1)
self.assertEqual(graph.edges[0].source, "manual") 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) listed = svc.list_maps(self.db)
self.assertGreaterEqual(listed["total"], 1) self.assertGreaterEqual(listed["total"], 1)

View file

@ -43,7 +43,7 @@ src/
| `/network/tasks/collect` | 采集任务 | `network` | | `/network/tasks/collect` | 采集任务 | `network` |
| `/network/tasks/config-sync` | 配置同步 | `network` | | `/network/tasks/config-sync` | 配置同步 | `network` |
| `/network/tasks/port-traffic` | 端口流量监控(任务向导 + 大屏) | `network` | | `/network/tasks/port-traffic` | 端口流量监控(任务向导 + 大屏) | `network` |
| `/topology` | 拓扑管理 | `topology` | | `/topology` | 拓扑管理(模式化编辑器:选择/平移/拖动/连线、框选、自动布局、拖放添加) | `topology` |
| `/webcrt` | WebCRT 终端 | `webcrt` | | `/webcrt` | WebCRT 终端 | `webcrt` |
| `/collect` | redirect → `/network/tasks/collect` | — | | `/collect` | redirect → `/network/tasks/collect` | — |
@ -117,6 +117,15 @@ src/
- 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天) - 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口) - 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口)
## 拓扑管理
- 渲染:`@xyflow/react`;布局:`dagre`(层次)+ 内置力导向/网格/环形
- 工具模式:选择(框选多选)/ 平移 / 拖动 / 连线;快捷键 `V` `H` `A` `C`
- 侧栏网元可点击或拖放到画布;发现链路后可选自动层次布局并写回坐标
- 支持对齐、网格吸附、轻量撤销/重做、链路图例
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式在右键菜单中调整
## WebCRT ## WebCRT
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}` - 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/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
"dagre": "^0.8.5",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
@ -19,6 +20,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/dagre": "^0.7.54",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@ -932,6 +934,13 @@
"@types/d3-selection": "*" "@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": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -1593,6 +1602,16 @@
"node": ">=12" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1981,6 +2000,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@ -2403,6 +2431,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "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/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
"dagre": "^0.8.5",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
@ -24,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/dagre": "^0.7.54",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

View file

@ -888,7 +888,7 @@ const en = {
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.", deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
emptyMaps: "No maps yet. Create one to start.", emptyMaps: "No maps yet. Create one to start.",
palette: "NE library", palette: "NE library",
paletteHint: "Click to add to canvas", paletteHint: "Click or drag onto the canvas",
filterPh: "Filter name / IP", filterPh: "Filter name / IP",
save: "Save", save: "Save",
saving: "Saving…", saving: "Saving…",
@ -900,13 +900,56 @@ const en = {
discoverFail: "Discovery failed: {{detail}}", discoverFail: "Discovery failed: {{detail}}",
discoverOneNeedNe: "This node is not linked to a scannable NE", discoverOneNeedNe: "This node is not linked to a scannable NE",
selectMap: "Select or create a topology map", selectMap: "Select or create a topology map",
canvasHint: "Add NEs from the left; blue dashed=found, red dashed=missing (deletable); 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", selected: "Selected",
selectedEdge: "Selected edge", 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", openWebcrt: "Open terminal",
openNe: "NE details", openNe: "NE details",
removeNode: "Remove from canvas", removeNode: "Remove from canvas",
removeEdge: "Delete edge", 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", noNeLink: "Not linked to a managed NE",
edgeManual: "Manual", edgeManual: "Manual",
edgeDiscovered: "Discovered", edgeDiscovered: "Discovered",

View file

@ -885,7 +885,7 @@ const zh = {
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。", deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
emptyMaps: "还没有拓扑图,先新建一张。", emptyMaps: "还没有拓扑图,先新建一张。",
palette: "网元库", palette: "网元库",
paletteHint: "点击添加到画布", paletteHint: "点击或拖到画布添加",
filterPh: "筛选名称 / IP", filterPh: "筛选名称 / IP",
save: "保存", save: "保存",
saving: "保存中…", saving: "保存中…",
@ -897,13 +897,56 @@ const zh = {
discoverFail: "发现失败:{{detail}}", discoverFail: "发现失败:{{detail}}",
discoverOneNeedNe: "该节点未关联可扫描网元", discoverOneNeedNe: "该节点未关联可扫描网元",
selectMap: "请选择或新建一张拓扑图", selectMap: "请选择或新建一张拓扑图",
canvasHint: "从左侧添加网元,拖动节点并连线;蓝虚线=发现链路,红虚线=本次未发现(可删);右键节点可操作", canvasHint:
"工具栏切换选择/平移/拖动/连线;框选多选;布局可自动构图。蓝虚线=发现链路,红虚线=未发现。快捷键 V/H/A/C,Ctrl+Z 撤销。",
selected: "已选节点", selected: "已选节点",
selectedEdge: "已选链路", 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: "打开终端", openWebcrt: "打开终端",
openNe: "网元详情", openNe: "网元详情",
removeNode: "从画布移除", removeNode: "从画布移除",
removeEdge: "删除链路", removeEdge: "删除链路",
nodeMenu: "网元",
selectionMenu: "多选",
edgeStyle: "链路样式",
edgeColor: "颜色",
edgeLineStyle: "线型",
edgeLineSolid: "实线",
edgeLineDashed: "虚线",
edgeLineDotted: "点线",
edgeWidth: "粗细",
edgeStyleReset: "恢复默认",
edgeDefaults: "默认链路样式",
edgeDefaultsReset: "恢复出厂",
noNeLink: "未关联托管网元", noNeLink: "未关联托管网元",
edgeManual: "人工", edgeManual: "人工",
edgeDiscovered: "发现", edgeDiscovered: "发现",
@ -911,7 +954,7 @@ const zh = {
fit: "适应画布", fit: "适应画布",
fullscreen: "全屏显示", fullscreen: "全屏显示",
exitFullscreen: "退出全屏", exitFullscreen: "退出全屏",
display: "显示选项", display: "显示",
hideIp: "隐藏 IP", hideIp: "隐藏 IP",
hideVendor: "隐藏厂商", hideVendor: "隐藏厂商",
hidePorts: "隐藏端口", hidePorts: "隐藏端口",

View file

@ -2232,22 +2232,37 @@ pre {
} }
.topo-toolbar { .topo-toolbar {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.topo-toolbar__row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 8px 12px; min-width: 0;
border-bottom: 1px solid #e2e8f0;
flex-wrap: nowrap;
} }
.topo-toolbar__left { .topo-toolbar__row--tools {
display: flex; justify-content: flex-start;
flex-direction: row; flex-wrap: wrap;
align-items: center; gap: 10px;
gap: 6px; padding-top: 2px;
min-width: 0; border-top: 1px solid #e2e8f0;
flex: 1 1 auto; }
.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 { .topo-toolbar__title {
@ -2261,33 +2276,221 @@ pre {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 14px;
}
.topo-toolbar__meta {
font-size: 12px;
color: #64748b;
white-space: nowrap;
} }
.topo-toolbar__actions { .topo-toolbar__actions {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: wrap;
align-items: center; 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; 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 { .topo-display-toggles {
display: flex; display: grid;
flex-wrap: nowrap; gap: 6px;
align-items: center;
gap: 10px;
margin-right: 4px;
font-size: 12px;
color: #475569;
} }
.topo-display-toggles__item { .topo-display-toggles__item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
font-size: 12px;
color: #334155;
white-space: nowrap;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
white-space: nowrap;
} }
.topo-ctx { .topo-ctx {
@ -2302,6 +2505,90 @@ pre {
border: 1px solid #334155; border: 1px solid #334155;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28); 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. */ /* Beat .app-main button { color:#1f2937; background:#fff } so hover stays readable. */
@ -2309,13 +2596,16 @@ pre {
.topo-ctx__item { .topo-ctx__item {
display: block; display: block;
width: 100%; width: 100%;
padding: 8px 14px; box-sizing: border-box;
padding: 7px 12px;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
color: #e2e8f0; color: #e2e8f0;
text-align: left; text-align: left;
font: inherit; font-size: 12px;
font-weight: 400;
line-height: 1.3;
cursor: pointer; cursor: pointer;
} }
@ -2481,40 +2771,31 @@ pre {
font-size: 11px; font-size: 11px;
text-align: center; text-align: center;
/* Default asset is blue (= ZTE). */ /* Default asset is blue (= ZTE). */
--topo-vendor: #0277bd;
--topo-icon-filter: none; --topo-icon-filter: none;
} }
.topo-node--cisco { .topo-node--cisco {
--topo-vendor: #1d4ed8;
--topo-icon-filter: hue-rotate(-8deg) saturate(1.15); --topo-icon-filter: hue-rotate(-8deg) saturate(1.15);
} }
.topo-node--huawei { .topo-node--huawei {
--topo-vendor: #c62828;
--topo-icon-filter: hue-rotate(155deg) saturate(1.35); --topo-icon-filter: hue-rotate(155deg) saturate(1.35);
} }
.topo-node--zte { .topo-node--zte {
--topo-vendor: #0277bd;
--topo-icon-filter: none; --topo-icon-filter: none;
} }
.topo-node--juniper { .topo-node--juniper {
--topo-vendor: #2e7d32;
--topo-icon-filter: hue-rotate(95deg) saturate(1.2); --topo-icon-filter: hue-rotate(95deg) saturate(1.2);
} }
.topo-node--nokia { .topo-node--nokia {
--topo-vendor: #00838f;
--topo-icon-filter: hue-rotate(-28deg) saturate(1.1) brightness(0.98); --topo-icon-filter: hue-rotate(-28deg) saturate(1.1) brightness(0.98);
} }
.topo-node--ericsson { .topo-node--ericsson {
--topo-vendor: #f9a825;
--topo-icon-filter: hue-rotate(175deg) saturate(1.45) brightness(1.05); --topo-icon-filter: hue-rotate(175deg) saturate(1.45) brightness(1.05);
} }
.topo-node--h3c { .topo-node--h3c {
--topo-vendor: #ef6c00;
--topo-icon-filter: hue-rotate(165deg) saturate(1.4); --topo-icon-filter: hue-rotate(165deg) saturate(1.4);
} }
.topo-node--other { .topo-node--other {
--topo-vendor: #546e7a;
--topo-icon-filter: grayscale(0.85) brightness(0.95); --topo-icon-filter: grayscale(0.85) brightness(0.95);
} }
@ -2526,13 +2807,18 @@ pre {
} }
.topo-node__handle { .topo-node__handle {
width: 12px !important; width: 10px !important;
height: 12px !important; height: 10px !important;
min-width: 12px !important; min-width: 10px !important;
min-height: 12px !important; min-height: 10px !important;
background: transparent !important; background: transparent !important;
border: 0 !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 { .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, PortTrafficDiscoverResponse,
PortTrafficSamples, PortTrafficSamples,
PortTrafficTarget, PortTrafficTarget,
PortTrafficTargetIn,
PortTrafficTask, PortTrafficTask,
} from "../types"; } from "../types";
@ -568,6 +569,9 @@ export const putTopologyGraph = (
source_port?: string; source_port?: string;
target_port?: string; target_port?: string;
source?: string; source?: string;
stroke_color?: string;
stroke_width?: number;
line_style?: string;
}>; }>;
}, },
) => apiPut<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body); ) => apiPut<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body);

View file

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