diff --git a/netx_api/main.py b/netx_api/main.py index d26e8cd..749b64f 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -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: diff --git a/netx_api/models.py b/netx_api/models.py index 5940517..d276af1 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -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) diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 8db50fb..c114efa 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -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 diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py index 8067f96..13ea610 100644 --- a/netx_api/topology_service.py +++ b/netx_api/topology_service.py @@ -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, diff --git a/tests/test_topology.py b/tests/test_topology.py index 9d61487..6529fb2 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -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) diff --git a/web/WEB.md b/web/WEB.md index d3dd367..754b7a1 100644 --- a/web/WEB.md +++ b/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}` diff --git a/web/package-lock.json b/web/package-lock.json index 91167fd..92cc7e4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 8567dd9..1c8db04 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 116e052..1b9a1f1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 2680edd..3e4faed 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "隐藏端口", diff --git a/web/src/index.css b/web/src/index.css index 39137fc..a502b76 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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 { diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 322a138..c010bd6 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ReactFlow, Background, + BackgroundVariant, Controls, ControlButton, MiniMap, @@ -13,6 +14,7 @@ import { Position, MarkerType, ConnectionMode, + SelectionMode, type Connection, type Edge, type Node, @@ -44,6 +46,12 @@ import type { TopologyNodeItem, UmeNeItem, } from "../types"; +import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph"; +import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode"; + +const SNAP_GRID: [number, number] = [16, 16]; +const UNDO_MAX = 40; +const PALETTE_DND = "application/x-netx-topo-palette"; function FullscreenIcon({ exit }: { exit?: boolean }) { if (exit) { @@ -75,6 +83,8 @@ type NeNodeData = { connect_status: string; }; +type HistorySnap = { nodes: Node[]; edges: Edge[] }; + type PaletteSource = "managed" | "ume"; type PaletteItem = { @@ -93,16 +103,19 @@ type TopoDisplayOpts = { hideIp: boolean; hideVendor: boolean; hidePorts: boolean; + connectMode: boolean; }; type CtxMenu = | { kind: "node"; id: string; x: number; y: number } - | { kind: "edge"; id: string; x: number; y: number }; + | { kind: "edge"; id: string; x: number; y: number } + | { kind: "selection"; x: number; y: number }; const TopoDisplayContext = createContext({ hideIp: true, hideVendor: true, hidePorts: true, + connectMode: false, }); function newId(): string { @@ -138,7 +151,7 @@ function RouterIcon() { } function NeNode({ data, selected }: NodeProps>) { - const { hideIp, hideVendor } = useContext(TopoDisplayContext); + const { hideIp, hideVendor, connectMode } = useContext(TopoDisplayContext); const tone = vendorTone(data.vendor); const name = data.label || (!hideIp ? data.ne_ip : "") || "NE"; const bits = [ @@ -151,11 +164,24 @@ function NeNode({ data, selected }: NodeProps>) { return arr.findIndex((y) => String(y || "").trim() === s) === i; }); return ( -
+
- {/* Centered on the disc so edges meet the circular icon, not the caption. */} - - + +
{bits.join(" · ")}
@@ -165,18 +191,119 @@ function NeNode({ data, selected }: NodeProps>) { const nodeTypes = { neNode: NeNode }; -function edgeStyle(source: string): { stroke: string; strokeDasharray?: string; strokeWidth?: number } { +type EdgeStyleData = { + source?: string; + source_port?: string; + target_port?: string; + stroke_color?: string; + stroke_width?: number; + line_style?: string; +}; + +type EdgeLineStyle = "solid" | "dashed" | "dotted"; +type EdgeSourceKind = "manual" | "discovered" | "stale"; + +type EdgeDefaultStyle = { + stroke_color: string; + stroke_width: number; + line_style: EdgeLineStyle; +}; + +type EdgeDefaults = Record; + +const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults"; + +const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = { + manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" }, + discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" }, + stale: { stroke_color: "#dc2626", stroke_width: 2, line_style: "dashed" }, +}; + +function sourceKind(source: string): EdgeSourceKind { const src = (source || "manual").toLowerCase(); - if (src === "stale") { - return { stroke: "#dc2626", strokeDasharray: "4 4", strokeWidth: 2 }; - } - if (src === "lldp" || src === "cdp") { - return { stroke: "#0ea5e9", strokeDasharray: "6 4" }; - } - return { stroke: "#64748b" }; + if (src === "stale") return "stale"; + if (src === "lldp" || src === "cdp") return "discovered"; + return "manual"; } -function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) { +function loadEdgeDefaults(): EdgeDefaults { + try { + const raw = localStorage.getItem(EDGE_DEFAULTS_KEY); + if (!raw) return { ...BUILTIN_EDGE_DEFAULTS, manual: { ...BUILTIN_EDGE_DEFAULTS.manual }, discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered }, stale: { ...BUILTIN_EDGE_DEFAULTS.stale } }; + const parsed = JSON.parse(raw) as Partial; + const pick = (kind: EdgeSourceKind): EdgeDefaultStyle => { + const base = BUILTIN_EDGE_DEFAULTS[kind]; + const cur = parsed?.[kind]; + const color = String(cur?.stroke_color || base.stroke_color).trim() || base.stroke_color; + const width = Math.max(1, Math.min(12, Number(cur?.stroke_width || base.stroke_width) || base.stroke_width)); + const line = String(cur?.line_style || base.line_style).toLowerCase(); + const line_style: EdgeLineStyle = + line === "dashed" || line === "dotted" || line === "solid" ? line : base.line_style; + return { stroke_color: color, stroke_width: width, line_style }; + }; + return { manual: pick("manual"), discovered: pick("discovered"), stale: pick("stale") }; + } catch { + return { + manual: { ...BUILTIN_EDGE_DEFAULTS.manual }, + discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered }, + stale: { ...BUILTIN_EDGE_DEFAULTS.stale }, + }; + } +} + +function persistEdgeDefaults(next: EdgeDefaults) { + try { + localStorage.setItem(EDGE_DEFAULTS_KEY, JSON.stringify(next)); + } catch { + /* ignore quota */ + } +} + +function dashForLineStyle(lineStyle: string): string | undefined { + const s = (lineStyle || "").trim().toLowerCase(); + if (s === "dashed") return "6 4"; + if (s === "dotted") return "2 2"; + return undefined; +} + +function edgeStyleBySource( + source: string, + defaults: EdgeDefaults, +): { stroke: string; strokeDasharray?: string; strokeWidth: number } { + const d = defaults[sourceKind(source)]; + return { + stroke: d.stroke_color, + strokeWidth: d.stroke_width, + strokeDasharray: dashForLineStyle(d.line_style), + }; +} + +function resolveEdgeStyle( + data: EdgeStyleData | undefined, + defaults: EdgeDefaults, +): { stroke: string; strokeDasharray?: string; strokeWidth: number } { + const base = edgeStyleBySource(data?.source || "manual", defaults); + const color = String(data?.stroke_color || "").trim(); + const width = Number(data?.stroke_width || 0); + const line = String(data?.line_style || "").trim().toLowerCase(); + return { + stroke: color || base.stroke, + strokeWidth: width > 0 ? width : base.strokeWidth, + strokeDasharray: line ? dashForLineStyle(line) : base.strokeDasharray, + }; +} + +function edgeMarker(stroke: string) { + return { type: MarkerType.ArrowClosed, width: 16, height: 16, color: stroke }; +} + +function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge { + const data = (edge.data || {}) as EdgeStyleData; + const style = resolveEdgeStyle(data, defaults); + return { ...edge, style, markerEnd: edgeMarker(style.stroke) }; +} + +function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defaults: EdgeDefaults) { const rfNodes: Node[] = nodes.map((n) => ({ id: n.id, type: "neNode", @@ -193,21 +320,26 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) { const rfEdges: Edge[] = edges.map((e) => { const src = e.source || "manual"; const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ "); - return { - id: e.id, - source: e.source_node_id, - target: e.target_node_id, - type: "straight", - label: label || undefined, - animated: false, - style: edgeStyle(src), - markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 }, - data: { - source: src, - source_port: e.source_port || "", - target_port: e.target_port || "", - }, + const data: EdgeStyleData = { + source: src, + source_port: e.source_port || "", + target_port: e.target_port || "", + stroke_color: e.stroke_color || "", + stroke_width: Number(e.stroke_width || 0), + line_style: e.line_style || "", }; + return withEdgeVisual( + { + id: e.id, + source: e.source_node_id, + target: e.target_node_id, + type: "straight", + label: label || undefined, + animated: false, + data, + }, + defaults, + ); }); return { rfNodes, rfEdges }; } @@ -222,14 +354,20 @@ function flowToGraphPayload(nodes: Node[], edges: Edge[]) { x: n.position.x, y: n.position.y, })), - edges: edges.map((e) => ({ - id: e.id, - source_node_id: e.source, - target_node_id: e.target, - source_port: String((e.data as { source_port?: string } | undefined)?.source_port || ""), - target_port: String((e.data as { target_port?: string } | undefined)?.target_port || ""), - source: String((e.data as { source?: string } | undefined)?.source || "manual"), - })), + edges: edges.map((e) => { + const data = (e.data || {}) as EdgeStyleData; + return { + id: e.id, + source_node_id: e.source, + target_node_id: e.target, + source_port: String(data.source_port || ""), + target_port: String(data.target_port || ""), + source: String(data.source || "manual"), + stroke_color: String(data.stroke_color || ""), + stroke_width: Number(data.stroke_width || 0), + line_style: String(data.line_style || ""), + }; + }), }; } @@ -239,13 +377,16 @@ export function TopologyPage() { const queryClient = useQueryClient(); const [mapId, setMapId] = useState(""); const [keyword, setKeyword] = useState(""); - const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); const [hideIp, setHideIp] = useState(true); const [hideVendor, setHideVendor] = useState(true); const [hidePorts, setHidePorts] = useState(true); const [edgeFlow, setEdgeFlow] = useState(false); + const [edgeDefaults, setEdgeDefaults] = useState(() => loadEdgeDefaults()); + const [toolMode, setToolMode] = useState("select"); + const [snapToGrid, setSnapToGrid] = useState(true); + const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(true); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [hideAddedNes, setHideAddedNes] = useState(true); const [paletteSource, setPaletteSource] = useState("managed"); @@ -269,9 +410,14 @@ export function TopologyPage() { const rfRef = useRef, Edge> | null>(null); const canvasRef = useRef(null); const dirtyRef = useRef(false); + const historyRef = useRef([]); + const redoRef = useRef([]); + const historyLockRef = useRef(false); + const connectClickRef = useRef(null); + const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]); const displayOpts = useMemo( - () => ({ hideIp, hideVendor, hidePorts }), - [hideIp, hideVendor, hidePorts], + () => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect" }), + [hideIp, hideVendor, hidePorts, toolMode], ); const displayEdges = useMemo( () => @@ -326,13 +472,125 @@ export function TopologyPage() { useEffect(() => { if (!graphQuery.data) return; - const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges); + const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults); + historyLockRef.current = true; setNodes(rfNodes); setEdges(rfEdges); + historyRef.current = []; + redoRef.current = []; dirtyRef.current = false; + historyLockRef.current = false; window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50); + // edgeDefaults applied separately so changing defaults won't reload the whole graph. + // eslint-disable-next-line react-hooks/exhaustive-deps -- only reload when graph payload changes }, [graphQuery.data, setNodes, setEdges]); + useEffect(() => { + setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults))); + }, [edgeDefaults, setEdges]); + + const updateEdgeDefault = useCallback((kind: EdgeSourceKind, patch: Partial) => { + setEdgeDefaults((prev) => { + const next: EdgeDefaults = { + ...prev, + [kind]: { ...prev[kind], ...patch }, + }; + persistEdgeDefaults(next); + return next; + }); + }, []); + + const resetEdgeDefaults = useCallback(() => { + const next: EdgeDefaults = { + manual: { ...BUILTIN_EDGE_DEFAULTS.manual }, + discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered }, + stale: { ...BUILTIN_EDGE_DEFAULTS.stale }, + }; + persistEdgeDefaults(next); + setEdgeDefaults(next); + }, []); + + const pushHistory = useCallback(() => { + if (historyLockRef.current) return; + historyRef.current = [ + ...historyRef.current.slice(-(UNDO_MAX - 1)), + { + nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), + edges: edges.map((e) => ({ ...e })), + }, + ]; + redoRef.current = []; + }, [nodes, edges]); + + const undo = useCallback(() => { + const prev = historyRef.current.pop(); + if (!prev) return; + redoRef.current.push({ + nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), + edges: edges.map((e) => ({ ...e })), + }); + historyLockRef.current = true; + setNodes(prev.nodes); + setEdges(prev.edges); + dirtyRef.current = true; + historyLockRef.current = false; + }, [nodes, edges, setNodes, setEdges]); + + const redo = useCallback(() => { + const next = redoRef.current.pop(); + if (!next) return; + historyRef.current.push({ + nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), + edges: edges.map((e) => ({ ...e })), + }); + historyLockRef.current = true; + setNodes(next.nodes); + setEdges(next.edges); + dirtyRef.current = true; + historyLockRef.current = false; + }, [nodes, edges, setNodes, setEdges]); + + const applyLayout = useCallback( + async (kind: LayoutKind, opts?: { onlySelected?: boolean; persist?: boolean }) => { + const onlyIds = + opts?.onlySelected + ? new Set(nodes.filter((n) => n.selected).map((n) => n.id)) + : undefined; + if (onlyIds && onlyIds.size === 0) { + showError(t("topology.layoutNeedSelection")); + return; + } + pushHistory(); + const next = layoutGraph(nodes, edges, kind, { onlyIds }); + setNodes(next); + dirtyRef.current = true; + window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 40); + if (opts?.persist && mapId) { + try { + const graph = await putTopologyGraph(mapId, flowToGraphPayload(next, edges)); + dirtyRef.current = false; + queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + } catch (err) { + showError(String(err)); + } + } + }, + [nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t], + ); + + const applyAlign = useCallback( + (kind: Parameters[2]) => { + const ids = nodes.filter((n) => n.selected).map((n) => n.id); + if (ids.length < 2) { + showError(t("topology.alignNeedSelection")); + return; + } + pushHistory(); + setNodes(alignNodes(nodes, ids, kind)); + dirtyRef.current = true; + }, + [nodes, setNodes, pushHistory, showError, t], + ); const renameMapMut = useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => updateTopologyMap(id, { name }), @@ -459,10 +717,24 @@ export function TopologyPage() { ); if (out.graph) { queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph); - const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges); + let { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges, edgeDefaults); + if (autoLayoutAfterDiscover && rfNodes.length > 1) { + rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb"); + try { + const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges)); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + dirtyRef.current = false; + } catch { + dirtyRef.current = true; + } + } else { + dirtyRef.current = false; + } + historyLockRef.current = true; setNodes(rfNodes); setEdges(rfEdges); - dirtyRef.current = false; + historyLockRef.current = false; + window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50); } setDiscoverReport(out); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); @@ -481,7 +753,7 @@ export function TopologyPage() { setDiscovering(false); } }, - [mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t], + [mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults], ); const discoverResults = discoverReport?.results?.length @@ -505,79 +777,124 @@ export function TopologyPage() { const onConnect = useCallback( (connection: Connection) => { + pushHistory(); dirtyRef.current = true; + connectClickRef.current = null; + const data: EdgeStyleData = { + source: "manual", + source_port: "", + target_port: "", + stroke_color: "", + stroke_width: 0, + line_style: "", + }; setEdges((eds) => addEdge( - { - ...connection, - id: newId(), - type: "straight", - markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 }, - style: { stroke: "#64748b" }, - data: { source: "manual", source_port: "", target_port: "" }, - }, + withEdgeVisual( + { + ...connection, + id: newId(), + type: "straight", + data, + }, + edgeDefaults, + ), eds, ), ); }, - [setEdges], + [setEdges, pushHistory, edgeDefaults], + ); + + const addNodeAt = useCallback( + (item: PaletteItem, position: { x: number; y: number }) => { + if (!mapId) { + showError(t("topology.selectMap")); + return; + } + if (item.source === "managed") { + if (nodes.some((n) => n.data.managed_ne_id === item.managed_ne_id)) return; + const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id); + if (!ne) return; + pushHistory(); + dirtyRef.current = true; + setNodes((prev) => [ + ...prev, + { + id: newId(), + type: "neNode", + position, + data: { + label: ne.name || ne.ip_address, + managed_ne_id: ne.id, + ume_ne_id: "", + ne_ip: ne.ip_address, + vendor: ne.vendor, + connect_status: ne.connect_status, + }, + }, + ]); + return; + } + if (nodes.some((n) => n.data.ume_ne_id === item.ume_ne_id)) return; + const ne = (umeQuery.data?.items || []).find((x) => x.ne_id === item.ume_ne_id); + if (!ne) return; + const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim(); + pushHistory(); + dirtyRef.current = true; + setNodes((prev) => [ + ...prev, + { + id: newId(), + type: "neNode", + position, + data: { + label: name, + managed_ne_id: "", + ume_ne_id: ne.ne_id, + ne_ip: ne.ip_address || "", + vendor: "ZTE", + connect_status: ne.connection_status || "", + }, + }, + ]); + }, + [mapId, nodes, neQuery.data, umeQuery.data, setNodes, pushHistory, showError, t], ); const addManagedNeToCanvas = (ne: ManagedNeItem) => { - if (!mapId) { - showError(t("topology.selectMap")); - return; - } - if (nodes.some((n) => n.data.managed_ne_id === ne.id)) { - return; - } - const offset = nodes.length * 24; - dirtyRef.current = true; - setNodes((prev) => [ - ...prev, + addNodeAt( { - id: newId(), - type: "neNode", - position: { x: 80 + offset, y: 80 + offset }, - data: { - label: ne.name || ne.ip_address, - managed_ne_id: ne.id, - ume_ne_id: "", - ne_ip: ne.ip_address, - vendor: ne.vendor, - connect_status: ne.connect_status, - }, + key: `managed:${ne.id}`, + source: "managed", + managed_ne_id: ne.id, + ume_ne_id: "", + name: ne.name || ne.ip_address, + ip: ne.ip_address, + vendor: ne.vendor, + meta: "", + connect_status: ne.connect_status, }, - ]); + { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 }, + ); }; const addUmeNeToCanvas = (ne: UmeNeItem) => { - if (!mapId) { - showError(t("topology.selectMap")); - return; - } - if (nodes.some((n) => n.data.ume_ne_id === ne.ne_id)) { - return; - } - const offset = nodes.length * 24; const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim(); - dirtyRef.current = true; - setNodes((prev) => [ - ...prev, + addNodeAt( { - id: newId(), - type: "neNode", - position: { x: 80 + offset, y: 80 + offset }, - data: { - label: name, - managed_ne_id: "", - ume_ne_id: ne.ne_id, - ne_ip: ne.ip_address || "", - vendor: "ZTE", - connect_status: ne.connection_status || "", - }, + key: `ume:${ne.ne_id}`, + source: "ume", + managed_ne_id: "", + ume_ne_id: ne.ne_id, + name, + ip: ne.ip_address || "", + vendor: "ZTE", + meta: "", + connect_status: ne.connection_status || "", }, - ]); + { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 }, + ); }; const addPaletteItem = (item: PaletteItem) => { @@ -590,10 +907,65 @@ export function TopologyPage() { if (ne) addUmeNeToCanvas(ne); }; - const selectedNode = useMemo( - () => nodes.find((n) => n.id === selectedNodeId) || null, - [nodes, selectedNodeId], + const onPaletteDragStart = (e: React.DragEvent, item: PaletteItem) => { + e.dataTransfer.setData(PALETTE_DND, JSON.stringify(item)); + e.dataTransfer.effectAllowed = "copy"; + }; + + const onCanvasDragOver = (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes(PALETTE_DND)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }; + + const onCanvasDrop = (e: React.DragEvent) => { + e.preventDefault(); + const raw = e.dataTransfer.getData(PALETTE_DND); + if (!raw || !rfRef.current) return; + try { + const item = JSON.parse(raw) as PaletteItem; + const pos = rfRef.current.screenToFlowPosition({ x: e.clientX, y: e.clientY }); + addNodeAt(item, pos); + } catch { + /* ignore */ + } + }; + const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]); + const selectedNode = selectedNodes[0] || null; + const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]); + const selectedEdge = useMemo( + () => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) || null : null), + [edges, selectedEdgeId], ); + const selectedEdgeData = (selectedEdge?.data || {}) as EdgeStyleData; + const selectedEdgeResolved = selectedEdge ? resolveEdgeStyle(selectedEdgeData, edgeDefaults) : null; + + const patchSelectedEdgeStyle = useCallback( + ( + patch: Partial>, + opts?: { skipHistory?: boolean }, + ) => { + if (!selectedEdgeId) return; + if (!opts?.skipHistory) pushHistory(); + dirtyRef.current = true; + setEdges((eds) => + eds.map((e) => { + if (e.id !== selectedEdgeId) return e; + const prev = (e.data || {}) as EdgeStyleData; + const data: EdgeStyleData = { + ...prev, + stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "", + stroke_width: + patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0), + line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "", + }; + return withEdgeVisual({ ...e, data }, edgeDefaults); + }), + ); + }, + [selectedEdgeId, setEdges, pushHistory, edgeDefaults], + ); + const staleEdgeCount = useMemo( () => edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale") @@ -630,29 +1002,94 @@ export function TopologyPage() { } }, [showError]); + const clearSelection = useCallback(() => { + setSelectedEdgeId(null); + setNodes((ns) => ns.map((n) => ({ ...n, selected: false }))); + setEdges((es) => es.map((e) => ({ ...e, selected: false }))); + connectClickRef.current = null; + }, [setNodes, setEdges]); + + const selectAllNodes = useCallback(() => { + setSelectedEdgeId(null); + setNodes((ns) => ns.map((n) => ({ ...n, selected: true }))); + setEdges((es) => es.map((e) => ({ ...e, selected: false }))); + }, [setNodes, setEdges]); + + const removeSelected = useCallback(() => { + const nodeIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id)); + const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id)); + if (nodeIds.size === 0 && edgeIds.size === 0) return; + pushHistory(); + dirtyRef.current = true; + setNodes((ns) => ns.filter((n) => !nodeIds.has(n.id))); + setEdges((es) => + es.filter((e) => !edgeIds.has(e.id) && !nodeIds.has(e.source) && !nodeIds.has(e.target)), + ); + setSelectedEdgeId(null); + closeCtxMenu(); + }, [nodes, edges, setNodes, setEdges, pushHistory, closeCtxMenu]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select" || (e.target as HTMLElement)?.isContentEditable) { + return; + } + if (e.key === "Escape") { + closeCtxMenu(); + clearSelection(); + return; + } + const mode = toolModeFromKey(e.key); + if (mode && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + setToolMode(mode); + connectClickRef.current = null; + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) { + e.preventDefault(); + undo(); + return; + } + if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) { + e.preventDefault(); + redo(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { + e.preventDefault(); + selectAllNodes(); + return; + } + if (e.key === "Delete" || e.key === "Backspace") { + if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) { + e.preventDefault(); + removeSelected(); + } + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges]); + useEffect(() => { if (!ctxMenu) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") closeCtxMenu(); - }; const onScroll = () => closeCtxMenu(); - window.addEventListener("keydown", onKey); window.addEventListener("scroll", onScroll, true); - return () => { - window.removeEventListener("keydown", onKey); - window.removeEventListener("scroll", onScroll, true); - }; + return () => window.removeEventListener("scroll", onScroll, true); }, [ctxMenu, closeCtxMenu]); const removeNodeById = (nodeId: string) => { + pushHistory(); dirtyRef.current = true; setNodes((ns) => ns.filter((n) => n.id !== nodeId)); setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId)); - setSelectedNodeId((cur) => (cur === nodeId ? null : cur)); closeCtxMenu(); }; const removeEdgeById = (edgeId: string) => { + pushHistory(); dirtyRef.current = true; setEdges((es) => es.filter((e) => e.id !== edgeId)); setSelectedEdgeId((cur) => (cur === edgeId ? null : cur)); @@ -662,6 +1099,7 @@ export function TopologyPage() { const removeStaleEdges = () => { const n = staleEdgeCount; if (n <= 0) return; + pushHistory(); dirtyRef.current = true; setEdges((es) => es.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") !== "stale"), @@ -722,28 +1160,34 @@ export function TopologyPage() { void runDiscover([id]); }; - const placeCtxMenu = (clientX: number, clientY: number): { x: number; y: number } => { + const placeCtxMenu = (clientX: number, clientY: number, size?: { w?: number; h?: number }): { x: number; y: number } => { const pad = 8; - const w = 180; - const h = 200; + const w = size?.w ?? 200; + const h = size?.h ?? 240; const x = Math.min(clientX, window.innerWidth - w - pad); const y = Math.min(clientY, window.innerHeight - h - pad); return { x: Math.max(pad, x), y: Math.max(pad, y) }; }; - const selectNodeOnly = useCallback( - (nodeId: string) => { + const focusNode = useCallback( + (nodeId: string, additive: boolean) => { setSelectedEdgeId(null); - setSelectedNodeId(nodeId); - setNodes((ns) => ns.map((n) => ({ ...n, selected: n.id === nodeId }))); + setNodes((ns) => + ns.map((n) => { + if (additive) { + if (n.id === nodeId) return { ...n, selected: !n.selected }; + return n; + } + return { ...n, selected: n.id === nodeId }; + }), + ); setEdges((es) => es.map((e) => ({ ...e, selected: false }))); }, [setNodes, setEdges], ); - const selectEdgeOnly = useCallback( + const focusEdge = useCallback( (edgeId: string) => { - setSelectedNodeId(null); setSelectedEdgeId(edgeId); setNodes((ns) => ns.map((n) => ({ ...n, selected: false }))); setEdges((es) => es.map((e) => ({ ...e, selected: e.id === edgeId }))); @@ -751,12 +1195,26 @@ export function TopologyPage() { [setNodes, setEdges], ); - const clearSelection = useCallback(() => { - setSelectedNodeId(null); - setSelectedEdgeId(null); - setNodes((ns) => ns.map((n) => ({ ...n, selected: false }))); - setEdges((es) => es.map((e) => ({ ...e, selected: false }))); - }, [setNodes, setEdges]); + const onNodeClick = useCallback( + (e: React.MouseEvent, node: Node) => { + setCtxMenu(null); + if (toolMode === "connect") { + const prev = connectClickRef.current; + if (!prev) { + connectClickRef.current = node.id; + focusNode(node.id, false); + return; + } + if (prev !== node.id) { + onConnect({ source: prev, target: node.id, sourceHandle: null, targetHandle: null }); + } + connectClickRef.current = null; + return; + } + focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey); + }, + [toolMode, focusNode, onConnect], + ); const maps = mapsQuery.data?.items || []; const onCanvasManagedIds = useMemo( @@ -929,7 +1387,7 @@ export function TopologyPage() { {t("topology.paletteUme")}
-

{t("topology.paletteHint")}

+

{t("topology.paletteHint")}

onPaletteDragStart(e, item)} onClick={() => addPaletteItem(item)} title={`${item.name}\n${item.ip}`} > @@ -984,80 +1444,238 @@ export function TopologyPage() {
-
+
{maps.find((m) => m.id === mapId)?.name || t("topology.selectMap")} + {selectedNodes.length > 0 || selectedEdgeId ? ( + + {selectedNodes.length > 0 + ? t("topology.selectedCount").replace("{{count}}", String(selectedNodes.length)) + : t("topology.selectedEdge")} + + ) : null}
-
-
-
- - - - +
+ + + + + +
- - - - +
+
+
+ {( + [ + ["select", t("topology.toolSelect"), "V"], + ["pan", t("topology.toolPan"), "H"], + ["drag", t("topology.toolDrag"), "A"], + ["connect", t("topology.toolConnect"), "C"], + ] as const + ).map(([mode, label, key]) => ( + + ))} +
+
+ + +
+
+ {t("topology.display")} +
+ + + + + + +
+
+ {t("topology.edgeDefaults")} + +
+ {( + [ + ["manual", t("topology.edgeManual")], + ["discovered", t("topology.edgeDiscovered")], + ["stale", t("topology.edgeStale")], + ] as const + ).map(([kind, label]) => { + const d = edgeDefaults[kind]; + return ( +
+ {label} + updateEdgeDefault(kind, { stroke_color: e.target.value })} + /> + + +
+ ); + })} +
+
+
@@ -1128,7 +1746,12 @@ export function TopologyPage() {
) : null} -
+
{mapId ? ( pushHistory()} onNodesChange={(changes) => { - if (changes.some((c) => c.type !== "select")) dirtyRef.current = true; + if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) { + dirtyRef.current = true; + } onNodesChange(changes); }} onEdgesChange={(changes) => { - if (changes.some((c) => c.type !== "select")) dirtyRef.current = true; + if (changes.some((c) => c.type !== "select")) { + dirtyRef.current = true; + } onEdgesChange(changes); }} onConnect={onConnect} - onNodeClick={(_e, node) => { - setCtxMenu(null); - selectNodeOnly(node.id); - }} + onNodeClick={onNodeClick} onEdgeClick={(_e, edge) => { setCtxMenu(null); - selectEdgeOnly(edge.id); + focusEdge(edge.id); }} onPaneClick={() => { setCtxMenu(null); @@ -1160,26 +1795,31 @@ export function TopologyPage() { }} onNodeContextMenu={(e, node) => { e.preventDefault(); - selectNodeOnly(node.id); + const multi = selectedNodeIds.length > 1 && selectedNodeIds.includes(node.id); + if (!multi) focusNode(node.id, false); const pos = placeCtxMenu(e.clientX, e.clientY); - setCtxMenu({ kind: "node", id: node.id, ...pos }); + setCtxMenu(multi ? { kind: "selection", ...pos } : { kind: "node", id: node.id, ...pos }); }} onEdgeContextMenu={(e, edge) => { e.preventDefault(); - selectEdgeOnly(edge.id); - const pos = placeCtxMenu(e.clientX, e.clientY); + focusEdge(edge.id); + const pos = placeCtxMenu(e.clientX, e.clientY, { w: 260, h: 280 }); setCtxMenu({ kind: "edge", id: edge.id, ...pos }); }} + onSelectionContextMenu={(e) => { + e.preventDefault(); + const pos = placeCtxMenu(e.clientX, e.clientY); + setCtxMenu({ kind: "selection", ...pos }); + }} onMoveStart={closeCtxMenu} onInit={(inst) => { - rfRef.current = inst; + rfRef.current = inst as ReactFlowInstance, Edge>; }} fitView - deleteKeyCode={["Backspace", "Delete"]} + deleteKeyCode={null} edgesFocusable - elementsSelectable > - + + ) : ( @@ -1201,13 +1865,54 @@ export function TopologyPage() { {ctxMenu ? (
    e.preventDefault()} > - {ctxMenu.kind === "node" ? ( + {ctxMenu.kind === "selection" ? ( <> +
  • + {t("topology.selectionMenu")} +
  • +
  • + +
  • +
  • +
  • + +
  • + + ) : ctxMenu.kind === "node" ? ( + <> +
  • + {t("topology.nodeMenu")} +
  • -
  • + <> +
  • +
    e.stopPropagation()}> +
    {t("topology.edgeStyle")}
    + {selectedEdgeResolved ? ( +
    + + + +
    + ) : null} +
    +
  • +
  • +
  • + +
  • +
  • + +
  • + )}
) : null} diff --git a/web/src/pages/topology/alignNodes.ts b/web/src/pages/topology/alignNodes.ts new file mode 100644 index 0000000..117a028 --- /dev/null +++ b/web/src/pages/topology/alignNodes.ts @@ -0,0 +1,64 @@ +import type { Node } from "@xyflow/react"; + +export type AlignKind = "left" | "right" | "top" | "bottom" | "hDistribute" | "vDistribute"; + +export function alignNodes>( + nodes: Node[], + kind: AlignKind, + selectedIds: Set, +): Node[] { + 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(); + 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; + }); +} diff --git a/web/src/pages/topology/layoutGraph.ts b/web/src/pages/topology/layoutGraph.ts new file mode 100644 index 0000000..851fb9f --- /dev/null +++ b/web/src/pages/topology/layoutGraph.ts @@ -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>( + nodes: Node[], + positions: Map, +): Node[] { + 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>( + nodes: Node[], + edges: Edge[], + rankdir: "TB" | "LR", +): Node[] { + 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(); + 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>( + nodes: Node[], + edges: Edge[], + iterations = 120, +): Node[] { + if (nodes.length === 0) return nodes; + const pos = new Map(); + const vel = new Map(); + 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>(nodes: Node[]): Node[] { + 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>(nodes: Node[]): Node[] { + 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>( + nodes: Node[], + edges: Edge[], + kind: LayoutKind, + opts?: { onlyIds?: Set }, +): Node[] { + 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[]; + 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>( + nodes: Node[], + ids: string[], + kind: "left" | "right" | "top" | "bottom" | "h-center" | "v-center" | "h-distribute" | "v-distribute", +): Node[] { + 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(); + 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); +} diff --git a/web/src/pages/topology/toolMode.ts b/web/src/pages/topology/toolMode.ts new file mode 100644 index 0000000..9187241 --- /dev/null +++ b/web/src/pages/topology/toolMode.ts @@ -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; +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 800fcef..24a0091 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body); diff --git a/web/src/types.ts b/web/src/types.ts index 63cbc2b..dee4c50 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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; };