mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
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:
parent
e4a365fa16
commit
99fce837f0
17 changed files with 1902 additions and 269 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
11
web/WEB.md
11
web/WEB.md
|
|
@ -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
34
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "隐藏端口",
|
||||
|
|
|
|||
|
|
@ -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
64
web/src/pages/topology/alignNodes.ts
Normal file
64
web/src/pages/topology/alignNodes.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
234
web/src/pages/topology/layoutGraph.ts
Normal file
234
web/src/pages/topology/layoutGraph.ts
Normal 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);
|
||||
}
|
||||
64
web/src/pages/topology/toolMode.ts
Normal file
64
web/src/pages/topology/toolMode.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue