mirror of
https://github.com/hansjone/netx.git
synced 2026-10-13 06:13:16 +08:00
Harden topology editor save/discover flow and edge editing.
Guard unsaved changes, cancel discovery, block invalid links, preserve discovered_at on PUT, and normalize interface names for discovery keys. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
99fce837f0
commit
2382cb8269
11 changed files with 416 additions and 128 deletions
|
|
@ -550,3 +550,51 @@ def _kv(text: str, pattern: str) -> str:
|
||||||
if not m:
|
if not m:
|
||||||
return ""
|
return ""
|
||||||
return str(m.group(1) or "").strip()
|
return str(m.group(1) or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# Long media names → short canonical form (case-insensitive prefix).
|
||||||
|
_IFNAME_PREFIXES: tuple[tuple[str, str], ...] = (
|
||||||
|
("tengigabitethernet", "te"),
|
||||||
|
("ten-gigabitethernet", "te"),
|
||||||
|
("gigabitethernet", "gi"),
|
||||||
|
("fastethernet", "fa"),
|
||||||
|
("ethernet", "eth"),
|
||||||
|
("xgigabitethernet", "xge"),
|
||||||
|
("100ge", "100ge"),
|
||||||
|
("40ge", "40ge"),
|
||||||
|
("25ge", "25ge"),
|
||||||
|
("10ge", "10ge"),
|
||||||
|
("ge-trunk", "ge-trunk"),
|
||||||
|
("eth-trunk", "eth-trunk"),
|
||||||
|
("port-channel", "po"),
|
||||||
|
("portchannel", "po"),
|
||||||
|
("loopback", "lo"),
|
||||||
|
("vlanif", "vlanif"),
|
||||||
|
("vlan", "vlan"),
|
||||||
|
("mgmteth", "mgmt"),
|
||||||
|
("management", "mgmt"),
|
||||||
|
("hundredgige", "hu"),
|
||||||
|
("fiftygige", "fi"),
|
||||||
|
("fortygige", "fo"),
|
||||||
|
("twentyfivegige", "twe"),
|
||||||
|
("twogigabitethernet", "tw"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ifname(name: str) -> str:
|
||||||
|
"""Canonicalize interface names so Gi0/0 and GigabitEthernet0/0 share a key."""
|
||||||
|
raw = str(name or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
s = re.sub(r"\s+", "", raw).lower()
|
||||||
|
s = s.replace("_", "/")
|
||||||
|
for long, short in _IFNAME_PREFIXES:
|
||||||
|
if s.startswith(long):
|
||||||
|
rest = s[len(long) :]
|
||||||
|
if rest.startswith((":", "/", "-")) or rest == "" or rest[0].isdigit():
|
||||||
|
if rest.startswith(":"):
|
||||||
|
rest = rest[1:]
|
||||||
|
return f"{short}{rest}"
|
||||||
|
break
|
||||||
|
# Already-short forms: gi0/0, te1/0/1, xge0/0/1, 10ge1/0/1
|
||||||
|
return s
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class TopologyNodeIn(BaseModel):
|
||||||
label: str = ""
|
label: str = ""
|
||||||
x: float = 0.0
|
x: float = 0.0
|
||||||
y: float = 0.0
|
y: float = 0.0
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class TopologyEdgeIn(BaseModel):
|
class TopologyEdgeIn(BaseModel):
|
||||||
|
|
@ -46,6 +47,8 @@ class TopologyEdgeIn(BaseModel):
|
||||||
stroke_color: str = Field(default="", max_length=32)
|
stroke_color: str = Field(default="", max_length=32)
|
||||||
stroke_width: int = Field(default=0, ge=0, le=12)
|
stroke_width: int = Field(default=0, ge=0, le=12)
|
||||||
line_style: str = Field(default="", max_length=16)
|
line_style: str = Field(default="", max_length=16)
|
||||||
|
discovered_at: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class TopologyNodeOut(BaseModel):
|
class TopologyNodeOut(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||||
from .cli_resolve import get_default_profile, infer_device_type_vendor
|
from .cli_resolve import get_default_profile, infer_device_type_vendor
|
||||||
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE
|
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE
|
||||||
from .ne_exec import execute_managed_ne_commands
|
from .ne_exec import execute_managed_ne_commands
|
||||||
from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
|
from .topology_lldp import NeighborHit, normalize_ifname, parse_neighbor_output, pick_neighbor_command
|
||||||
from .topology_schemas import (
|
from .topology_schemas import (
|
||||||
TopologyDiscoverNeResult,
|
TopologyDiscoverNeResult,
|
||||||
TopologyDiscoverOut,
|
TopologyDiscoverOut,
|
||||||
|
|
@ -251,6 +251,8 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
|
||||||
tid = str(e.target_node_id or "").strip()
|
tid = str(e.target_node_id or "").strip()
|
||||||
if sid not in node_ids or tid not in node_ids:
|
if sid not in node_ids or tid not in node_ids:
|
||||||
raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes")
|
raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes")
|
||||||
|
if sid == tid:
|
||||||
|
raise HTTPException(status_code=400, detail="edge_self_loop")
|
||||||
src = str(e.source or "manual").strip().lower() or "manual"
|
src = str(e.source or "manual").strip().lower() or "manual"
|
||||||
if src not in {"manual", "lldp", "cdp", "stale"}:
|
if src not in {"manual", "lldp", "cdp", "stale"}:
|
||||||
raise HTTPException(status_code=400, detail="invalid_edge_source")
|
raise HTTPException(status_code=400, detail="invalid_edge_source")
|
||||||
|
|
@ -262,27 +264,47 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
|
||||||
normalized_edges.append((e, src, color, width, line))
|
normalized_edges.append((e, src, color, width, line))
|
||||||
|
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
|
prev_nodes = {
|
||||||
|
str(n.id): {"created_at": n.created_at}
|
||||||
|
for n in db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all()
|
||||||
|
}
|
||||||
|
prev_edges = {
|
||||||
|
str(e.id): {"created_at": e.created_at, "discovered_at": e.discovered_at}
|
||||||
|
for e in db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all()
|
||||||
|
}
|
||||||
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
|
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
|
||||||
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
|
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
|
||||||
|
db.expire_all()
|
||||||
|
|
||||||
for n in nodes_in:
|
for n in nodes_in:
|
||||||
|
nid = str(n.id).strip()
|
||||||
|
prev = prev_nodes.get(nid) or {}
|
||||||
|
created = getattr(n, "created_at", None) or prev.get("created_at") or now
|
||||||
db.add(
|
db.add(
|
||||||
TopologyNode(
|
TopologyNode(
|
||||||
id=str(n.id).strip(),
|
id=nid,
|
||||||
map_id=row.id,
|
map_id=row.id,
|
||||||
managed_ne_id=str(n.managed_ne_id or "").strip(),
|
managed_ne_id=str(n.managed_ne_id or "").strip(),
|
||||||
ume_ne_id=str(n.ume_ne_id or "").strip(),
|
ume_ne_id=str(n.ume_ne_id or "").strip(),
|
||||||
label=str(n.label or "").strip()[:256],
|
label=str(n.label or "").strip()[:256],
|
||||||
x=float(n.x or 0),
|
x=float(n.x or 0),
|
||||||
y=float(n.y or 0),
|
y=float(n.y or 0),
|
||||||
created_at=now,
|
created_at=created,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for e, src, color, width, line in normalized_edges:
|
for e, src, color, width, line in normalized_edges:
|
||||||
|
eid = str(e.id).strip() or uuid4().hex
|
||||||
|
prev = prev_edges.get(eid) or {}
|
||||||
|
created = getattr(e, "created_at", None) or prev.get("created_at") or now
|
||||||
|
client_discovered = getattr(e, "discovered_at", None)
|
||||||
|
if src in {"lldp", "cdp", "stale"}:
|
||||||
|
discovered = client_discovered or prev.get("discovered_at") or now
|
||||||
|
else:
|
||||||
|
discovered = None
|
||||||
db.add(
|
db.add(
|
||||||
TopologyEdge(
|
TopologyEdge(
|
||||||
id=str(e.id).strip() or uuid4().hex,
|
id=eid,
|
||||||
map_id=row.id,
|
map_id=row.id,
|
||||||
source_node_id=str(e.source_node_id).strip(),
|
source_node_id=str(e.source_node_id).strip(),
|
||||||
target_node_id=str(e.target_node_id).strip(),
|
target_node_id=str(e.target_node_id).strip(),
|
||||||
|
|
@ -292,8 +314,8 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
|
||||||
stroke_color=color,
|
stroke_color=color,
|
||||||
stroke_width=width,
|
stroke_width=width,
|
||||||
line_style=line,
|
line_style=line,
|
||||||
discovered_at=now if src in {"lldp", "cdp", "stale"} else None,
|
discovered_at=discovered,
|
||||||
created_at=now,
|
created_at=created,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -345,9 +367,11 @@ def _match_neighbor_to_node(
|
||||||
|
|
||||||
|
|
||||||
def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[str, str, str, str]:
|
def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[str, str, str, str]:
|
||||||
|
lp = normalize_ifname(local_port)
|
||||||
|
rp = normalize_ifname(remote_port)
|
||||||
if a <= b:
|
if a <= b:
|
||||||
return (a, b, local_port, remote_port)
|
return (a, b, lp, rp)
|
||||||
return (b, a, remote_port, local_port)
|
return (b, a, rp, lp)
|
||||||
|
|
||||||
|
|
||||||
def _discover_target_for_node(
|
def _discover_target_for_node(
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,75 @@ class TopologyServiceTests(unittest.TestCase):
|
||||||
with self.assertRaises(HTTPException):
|
with self.assertRaises(HTTPException):
|
||||||
svc.get_graph(self.db, mid)
|
svc.get_graph(self.db, mid)
|
||||||
|
|
||||||
|
def test_put_preserves_discovered_at_and_rejects_self_loop(self) -> None:
|
||||||
|
created = svc.create_map(self.db, TopologyMapCreate(name="Preserve", remark=""))
|
||||||
|
mid = created.id
|
||||||
|
first = svc.put_graph(
|
||||||
|
self.db,
|
||||||
|
mid,
|
||||||
|
TopologyGraphPut(
|
||||||
|
nodes=[
|
||||||
|
TopologyNodeIn(id="n1", label="A", x=0, y=0),
|
||||||
|
TopologyNodeIn(id="n2", label="B", x=10, y=0),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
TopologyEdgeIn(
|
||||||
|
id="e1",
|
||||||
|
source_node_id="n1",
|
||||||
|
target_node_id="n2",
|
||||||
|
source="lldp",
|
||||||
|
source_port="Gi0/0",
|
||||||
|
target_port="Gi0/1",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
discovered = first.edges[0].discovered_at
|
||||||
|
self.assertIsNotNone(discovered)
|
||||||
|
|
||||||
|
second = svc.put_graph(
|
||||||
|
self.db,
|
||||||
|
mid,
|
||||||
|
TopologyGraphPut(
|
||||||
|
nodes=[
|
||||||
|
TopologyNodeIn(id="n1", label="A", x=1, y=1),
|
||||||
|
TopologyNodeIn(id="n2", label="B", x=11, y=1),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
TopologyEdgeIn(
|
||||||
|
id="e1",
|
||||||
|
source_node_id="n1",
|
||||||
|
target_node_id="n2",
|
||||||
|
source="lldp",
|
||||||
|
source_port="Gi0/0",
|
||||||
|
target_port="Gi0/1",
|
||||||
|
stroke_color="#0ea5e9",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(second.edges[0].discovered_at, discovered)
|
||||||
|
self.assertEqual(second.edges[0].stroke_color, "#0ea5e9")
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
svc.put_graph(
|
||||||
|
self.db,
|
||||||
|
mid,
|
||||||
|
TopologyGraphPut(
|
||||||
|
nodes=[TopologyNodeIn(id="n1", label="A", x=0, y=0)],
|
||||||
|
edges=[
|
||||||
|
TopologyEdgeIn(
|
||||||
|
id="bad",
|
||||||
|
source_node_id="n1",
|
||||||
|
target_node_id="n1",
|
||||||
|
source="manual",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(ctx.exception.detail, "edge_self_loop")
|
||||||
|
svc.delete_map(self.db, mid)
|
||||||
|
|
||||||
def test_discover_matches_by_name_and_skips_manual(self) -> None:
|
def test_discover_matches_by_name_and_skips_manual(self) -> None:
|
||||||
suffix = uuid4().hex[:8]
|
suffix = uuid4().hex[:8]
|
||||||
ne_a_id = f"nea-{suffix}"
|
ne_a_id = f"nea-{suffix}"
|
||||||
|
|
@ -528,5 +597,20 @@ class TopologyServiceTests(unittest.TestCase):
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class IfnameNormalizeTests(unittest.TestCase):
|
||||||
|
def test_cisco_huawei_aliases(self) -> None:
|
||||||
|
self.assertEqual(lldp.normalize_ifname("GigabitEthernet0/0"), "gi0/0")
|
||||||
|
self.assertEqual(lldp.normalize_ifname("Gi0/0"), "gi0/0")
|
||||||
|
self.assertEqual(lldp.normalize_ifname("TenGigabitEthernet1/0/1"), "te1/0/1")
|
||||||
|
self.assertEqual(lldp.normalize_ifname("XGigabitEthernet0/0/1"), "xge0/0/1")
|
||||||
|
self.assertEqual(lldp.normalize_ifname("Eth-Trunk1"), "eth-trunk1")
|
||||||
|
self.assertEqual(lldp.normalize_ifname(""), "")
|
||||||
|
|
||||||
|
def test_edge_pair_key_aliases_match(self) -> None:
|
||||||
|
a = svc._edge_pair_key("n1", "n2", "GigabitEthernet0/0", "Gi0/1")
|
||||||
|
b = svc._edge_pair_key("n1", "n2", "Gi0/0", "GigabitEthernet0/1")
|
||||||
|
self.assertEqual(a, b)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,8 @@ src/
|
||||||
- 侧栏网元可点击或拖放到画布;发现链路后可选自动层次布局并写回坐标
|
- 侧栏网元可点击或拖放到画布;发现链路后可选自动层次布局并写回坐标
|
||||||
- 支持对齐、网格吸附、轻量撤销/重做、链路图例
|
- 支持对齐、网格吸附、轻量撤销/重做、链路图例
|
||||||
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
|
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
|
||||||
- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式在右键菜单中调整
|
- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名
|
||||||
|
- 未保存切换地图会确认;Ctrl+S 保存;发现可取消;禁止自环与重复连线;PUT 保留 `discovered_at`;发现边键对端口名做规范化
|
||||||
|
|
||||||
## WebCRT
|
## WebCRT
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -891,6 +891,8 @@ const en = {
|
||||||
paletteHint: "Click or drag onto the canvas",
|
paletteHint: "Click or drag onto the canvas",
|
||||||
filterPh: "Filter name / IP",
|
filterPh: "Filter name / IP",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
|
saveDirty: "Save *",
|
||||||
|
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
|
||||||
saving: "Saving…",
|
saving: "Saving…",
|
||||||
saved: "Topology saved",
|
saved: "Topology saved",
|
||||||
discover: "Discover all links",
|
discover: "Discover all links",
|
||||||
|
|
@ -900,6 +902,10 @@ const en = {
|
||||||
discoverFail: "Discovery failed: {{detail}}",
|
discoverFail: "Discovery failed: {{detail}}",
|
||||||
discoverOneNeedNe: "This node is not linked to a scannable NE",
|
discoverOneNeedNe: "This node is not linked to a scannable NE",
|
||||||
selectMap: "Select or create a topology map",
|
selectMap: "Select or create a topology map",
|
||||||
|
renameNode: "Rename",
|
||||||
|
renameNodePrompt: "Node display name",
|
||||||
|
sourcePort: "Local port",
|
||||||
|
targetPort: "Remote port",
|
||||||
canvasHint:
|
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.",
|
"Use toolbar modes (select/pan/drag/connect), box-select, and layout. Blue dashed=discovered, red=missing. Shortcuts V/H/A/C, Ctrl+Z undo.",
|
||||||
selected: "Selected",
|
selected: "Selected",
|
||||||
|
|
@ -973,6 +979,7 @@ const en = {
|
||||||
paletteLoading: "Loading…",
|
paletteLoading: "Loading…",
|
||||||
discoverReport: "Discovery progress",
|
discoverReport: "Discovery progress",
|
||||||
discoverClose: "Close",
|
discoverClose: "Close",
|
||||||
|
discoverCancel: "Cancel discovery",
|
||||||
discoverProgress: "Scanning {{count}} managed NE(s) on the map (serial LLDP; please wait)…",
|
discoverProgress: "Scanning {{count}} managed NE(s) on the map (serial LLDP; please wait)…",
|
||||||
discoverProgressLive: "Scanning {{index}}/{{total}}: {{name}}",
|
discoverProgressLive: "Scanning {{index}}/{{total}}: {{name}}",
|
||||||
discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}",
|
discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}",
|
||||||
|
|
|
||||||
|
|
@ -888,6 +888,8 @@ const zh = {
|
||||||
paletteHint: "点击或拖到画布添加",
|
paletteHint: "点击或拖到画布添加",
|
||||||
filterPh: "筛选名称 / IP",
|
filterPh: "筛选名称 / IP",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
|
saveDirty: "保存 *",
|
||||||
|
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
||||||
saving: "保存中…",
|
saving: "保存中…",
|
||||||
saved: "拓扑已保存",
|
saved: "拓扑已保存",
|
||||||
discover: "发现全量链路",
|
discover: "发现全量链路",
|
||||||
|
|
@ -897,6 +899,10 @@ const zh = {
|
||||||
discoverFail: "发现失败:{{detail}}",
|
discoverFail: "发现失败:{{detail}}",
|
||||||
discoverOneNeedNe: "该节点未关联可扫描网元",
|
discoverOneNeedNe: "该节点未关联可扫描网元",
|
||||||
selectMap: "请选择或新建一张拓扑图",
|
selectMap: "请选择或新建一张拓扑图",
|
||||||
|
renameNode: "重命名",
|
||||||
|
renameNodePrompt: "网元显示名称",
|
||||||
|
sourcePort: "本端端口",
|
||||||
|
targetPort: "对端端口",
|
||||||
canvasHint:
|
canvasHint:
|
||||||
"工具栏切换选择/平移/拖动/连线;框选多选;布局可自动构图。蓝虚线=发现链路,红虚线=未发现。快捷键 V/H/A/C,Ctrl+Z 撤销。",
|
"工具栏切换选择/平移/拖动/连线;框选多选;布局可自动构图。蓝虚线=发现链路,红虚线=未发现。快捷键 V/H/A/C,Ctrl+Z 撤销。",
|
||||||
selected: "已选节点",
|
selected: "已选节点",
|
||||||
|
|
@ -970,6 +976,7 @@ const zh = {
|
||||||
paletteLoading: "加载中…",
|
paletteLoading: "加载中…",
|
||||||
discoverReport: "发现进度",
|
discoverReport: "发现进度",
|
||||||
discoverClose: "关闭",
|
discoverClose: "关闭",
|
||||||
|
discoverCancel: "取消发现",
|
||||||
discoverProgress: "正在扫描画布上 {{count}} 台托管网元(串行登录采集 LLDP,请稍候)…",
|
discoverProgress: "正在扫描画布上 {{count}} 台托管网元(串行登录采集 LLDP,请稍候)…",
|
||||||
discoverProgressLive: "正在扫描 {{index}}/{{total}}:{{name}}",
|
discoverProgressLive: "正在扫描 {{index}}/{{total}}:{{name}}",
|
||||||
discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}",
|
discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}",
|
||||||
|
|
|
||||||
|
|
@ -2578,7 +2578,8 @@ pre {
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-ctx__style-row select {
|
.topo-ctx__style-row select,
|
||||||
|
.topo-ctx__style-input {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
max-width: 108px;
|
max-width: 108px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|
@ -2591,6 +2592,11 @@ pre {
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-ctx__style-input {
|
||||||
|
max-width: 120px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Beat .app-main button { color:#1f2937; background:#fff } so hover stays readable. */
|
/* Beat .app-main button { color:#1f2937; background:#fff } so hover stays readable. */
|
||||||
.app-main .topo-ctx__item,
|
.app-main .topo-ctx__item,
|
||||||
.topo-ctx__item {
|
.topo-ctx__item {
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||||
/>
|
/>
|
||||||
<RouterIcon />
|
<RouterIcon />
|
||||||
</div>
|
</div>
|
||||||
<div className="topo-node__caption">{bits.join(" · ")}</div>
|
<div className="topo-node__caption">{bits.join(" ? ")}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +198,7 @@ type EdgeStyleData = {
|
||||||
stroke_color?: string;
|
stroke_color?: string;
|
||||||
stroke_width?: number;
|
stroke_width?: number;
|
||||||
line_style?: string;
|
line_style?: string;
|
||||||
|
discovered_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EdgeLineStyle = "solid" | "dashed" | "dotted";
|
type EdgeLineStyle = "solid" | "dashed" | "dotted";
|
||||||
|
|
@ -319,7 +320,7 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau
|
||||||
}));
|
}));
|
||||||
const rfEdges: Edge[] = edges.map((e) => {
|
const rfEdges: Edge[] = edges.map((e) => {
|
||||||
const src = e.source || "manual";
|
const src = e.source || "manual";
|
||||||
const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ ");
|
const label = [e.source_port, e.target_port].filter(Boolean).join(" ? ");
|
||||||
const data: EdgeStyleData = {
|
const data: EdgeStyleData = {
|
||||||
source: src,
|
source: src,
|
||||||
source_port: e.source_port || "",
|
source_port: e.source_port || "",
|
||||||
|
|
@ -327,6 +328,7 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau
|
||||||
stroke_color: e.stroke_color || "",
|
stroke_color: e.stroke_color || "",
|
||||||
stroke_width: Number(e.stroke_width || 0),
|
stroke_width: Number(e.stroke_width || 0),
|
||||||
line_style: e.line_style || "",
|
line_style: e.line_style || "",
|
||||||
|
discovered_at: e.discovered_at ?? null,
|
||||||
};
|
};
|
||||||
return withEdgeVisual(
|
return withEdgeVisual(
|
||||||
{
|
{
|
||||||
|
|
@ -366,6 +368,7 @@ function flowToGraphPayload(nodes: Node<NeNodeData>[], edges: Edge[]) {
|
||||||
stroke_color: String(data.stroke_color || ""),
|
stroke_color: String(data.stroke_color || ""),
|
||||||
stroke_width: Number(data.stroke_width || 0),
|
stroke_width: Number(data.stroke_width || 0),
|
||||||
line_style: String(data.line_style || ""),
|
line_style: String(data.line_style || ""),
|
||||||
|
discovered_at: data.discovered_at ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
@ -377,6 +380,9 @@ export function TopologyPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [mapId, setMapId] = useState<string>("");
|
const [mapId, setMapId] = useState<string>("");
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [historyTick, setHistoryTick] = useState(0);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null);
|
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null);
|
||||||
const [hideIp, setHideIp] = useState(true);
|
const [hideIp, setHideIp] = useState(true);
|
||||||
|
|
@ -414,6 +420,52 @@ export function TopologyPage() {
|
||||||
const redoRef = useRef<HistorySnap[]>([]);
|
const redoRef = useRef<HistorySnap[]>([]);
|
||||||
const historyLockRef = useRef(false);
|
const historyLockRef = useRef(false);
|
||||||
const connectClickRef = useRef<string | null>(null);
|
const connectClickRef = useRef<string | null>(null);
|
||||||
|
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
||||||
|
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
||||||
|
|
||||||
|
const markDirty = useCallback(() => {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearDirty = useCallback(() => {
|
||||||
|
dirtyRef.current = false;
|
||||||
|
setDirty(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const bumpHistory = useCallback(() => {
|
||||||
|
setHistoryTick((n) => n + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmDiscardIfDirty = useCallback(() => {
|
||||||
|
if (!dirtyRef.current) return true;
|
||||||
|
return window.confirm(t("topology.unsavedConfirm"));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const selectMap = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (id === mapId) return;
|
||||||
|
if (!confirmDiscardIfDirty()) return;
|
||||||
|
setMapId(id);
|
||||||
|
},
|
||||||
|
[mapId, confirmDiscardIfDirty],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [keyword]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (!dirtyRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = "";
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", onBeforeUnload);
|
||||||
|
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]);
|
const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]);
|
||||||
const displayOpts = useMemo(
|
const displayOpts = useMemo(
|
||||||
() => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect" }),
|
() => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect" }),
|
||||||
|
|
@ -441,10 +493,10 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const neQuery = useQuery({
|
const neQuery = useQuery({
|
||||||
queryKey: [...queryKeys.managedNeAll, "topology-palette", keyword],
|
queryKey: [...queryKeys.managedNeAll, "topology-palette", debouncedKeyword],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchManagedNe({
|
fetchManagedNe({
|
||||||
keyword: keyword.trim(),
|
keyword: debouncedKeyword,
|
||||||
vendor: "",
|
vendor: "",
|
||||||
connectStatus: "",
|
connectStatus: "",
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|
@ -454,10 +506,10 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const umeQuery = useQuery({
|
const umeQuery = useQuery({
|
||||||
queryKey: ["umeInventoryNe", "topology-palette", keyword],
|
queryKey: ["umeInventoryNe", "topology-palette", debouncedKeyword],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchUmeNe({
|
fetchUmeNe({
|
||||||
keyword: keyword.trim(),
|
keyword: debouncedKeyword,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 100,
|
pageSize: 100,
|
||||||
}),
|
}),
|
||||||
|
|
@ -478,7 +530,8 @@ export function TopologyPage() {
|
||||||
setEdges(rfEdges);
|
setEdges(rfEdges);
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
redoRef.current = [];
|
redoRef.current = [];
|
||||||
dirtyRef.current = false;
|
clearDirty();
|
||||||
|
bumpHistory();
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||||
// edgeDefaults applied separately so changing defaults won't reload the whole graph.
|
// edgeDefaults applied separately so changing defaults won't reload the whole graph.
|
||||||
|
|
@ -520,7 +573,8 @@ export function TopologyPage() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
redoRef.current = [];
|
redoRef.current = [];
|
||||||
}, [nodes, edges]);
|
bumpHistory();
|
||||||
|
}, [nodes, edges, bumpHistory]);
|
||||||
|
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback(() => {
|
||||||
const prev = historyRef.current.pop();
|
const prev = historyRef.current.pop();
|
||||||
|
|
@ -532,9 +586,10 @@ export function TopologyPage() {
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
setNodes(prev.nodes);
|
setNodes(prev.nodes);
|
||||||
setEdges(prev.edges);
|
setEdges(prev.edges);
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
|
bumpHistory();
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
}, [nodes, edges, setNodes, setEdges]);
|
}, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]);
|
||||||
|
|
||||||
const redo = useCallback(() => {
|
const redo = useCallback(() => {
|
||||||
const next = redoRef.current.pop();
|
const next = redoRef.current.pop();
|
||||||
|
|
@ -546,9 +601,10 @@ export function TopologyPage() {
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
setNodes(next.nodes);
|
setNodes(next.nodes);
|
||||||
setEdges(next.edges);
|
setEdges(next.edges);
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
|
bumpHistory();
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
}, [nodes, edges, setNodes, setEdges]);
|
}, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]);
|
||||||
|
|
||||||
const applyLayout = useCallback(
|
const applyLayout = useCallback(
|
||||||
async (kind: LayoutKind, opts?: { onlySelected?: boolean; persist?: boolean }) => {
|
async (kind: LayoutKind, opts?: { onlySelected?: boolean; persist?: boolean }) => {
|
||||||
|
|
@ -563,19 +619,19 @@ export function TopologyPage() {
|
||||||
pushHistory();
|
pushHistory();
|
||||||
const next = layoutGraph(nodes, edges, kind, { onlyIds });
|
const next = layoutGraph(nodes, edges, kind, { onlyIds });
|
||||||
setNodes(next);
|
setNodes(next);
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 40);
|
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 40);
|
||||||
if (opts?.persist && mapId) {
|
if (opts?.persist && mapId) {
|
||||||
try {
|
try {
|
||||||
const graph = await putTopologyGraph(mapId, flowToGraphPayload(next, edges));
|
const graph = await putTopologyGraph(mapId, flowToGraphPayload(next, edges));
|
||||||
dirtyRef.current = false;
|
clearDirty();
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(err));
|
showError(String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t],
|
[nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t, markDirty, clearDirty],
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyAlign = useCallback(
|
const applyAlign = useCallback(
|
||||||
|
|
@ -587,9 +643,9 @@ export function TopologyPage() {
|
||||||
}
|
}
|
||||||
pushHistory();
|
pushHistory();
|
||||||
setNodes(alignNodes(nodes, ids, kind));
|
setNodes(alignNodes(nodes, ids, kind));
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
},
|
},
|
||||||
[nodes, setNodes, pushHistory, showError, t],
|
[nodes, setNodes, pushHistory, showError, t, markDirty],
|
||||||
);
|
);
|
||||||
const renameMapMut = useMutation({
|
const renameMapMut = useMutation({
|
||||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||||
|
|
@ -616,6 +672,7 @@ export function TopologyPage() {
|
||||||
mutationFn: () => createTopologyMap({ name: t("topology.newMapName") }),
|
mutationFn: () => createTopologyMap({ name: t("topology.newMapName") }),
|
||||||
onSuccess: async (row) => {
|
onSuccess: async (row) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
clearDirty();
|
||||||
setMapId(row.id);
|
setMapId(row.id);
|
||||||
showOk(t("topology.newMap"));
|
showOk(t("topology.newMap"));
|
||||||
// Prompt rename right after create — default name is a placeholder.
|
// Prompt rename right after create — default name is a placeholder.
|
||||||
|
|
@ -629,6 +686,7 @@ export function TopologyPage() {
|
||||||
onSuccess: async (_out, id) => {
|
onSuccess: async (_out, id) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
if (mapId === id) {
|
if (mapId === id) {
|
||||||
|
clearDirty();
|
||||||
setMapId("");
|
setMapId("");
|
||||||
setNodes([]);
|
setNodes([]);
|
||||||
setEdges([]);
|
setEdges([]);
|
||||||
|
|
@ -640,7 +698,7 @@ export function TopologyPage() {
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)),
|
mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)),
|
||||||
onSuccess: async (graph) => {
|
onSuccess: async (graph) => {
|
||||||
dirtyRef.current = false;
|
clearDirty();
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
showOk(t("topology.saved"));
|
showOk(t("topology.saved"));
|
||||||
|
|
@ -679,7 +737,7 @@ export function TopologyPage() {
|
||||||
try {
|
try {
|
||||||
if (dirtyRef.current) {
|
if (dirtyRef.current) {
|
||||||
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
|
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
|
||||||
dirtyRef.current = false;
|
clearDirty();
|
||||||
}
|
}
|
||||||
const out = await discoverTopologyNeighborsStream(
|
const out = await discoverTopologyNeighborsStream(
|
||||||
mapId,
|
mapId,
|
||||||
|
|
@ -723,12 +781,12 @@ export function TopologyPage() {
|
||||||
try {
|
try {
|
||||||
const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges));
|
const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges));
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
dirtyRef.current = false;
|
clearDirty();
|
||||||
} catch {
|
} catch {
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dirtyRef.current = false;
|
clearDirty();
|
||||||
}
|
}
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
setNodes(rfNodes);
|
setNodes(rfNodes);
|
||||||
|
|
@ -775,10 +833,21 @@ export function TopologyPage() {
|
||||||
failed: discoverLiveResults.filter((r) => !r.ok).length,
|
failed: discoverLiveResults.filter((r) => !r.ok).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isValidConnection = useCallback(
|
||||||
|
(connection: Connection | Edge) => {
|
||||||
|
const source = String(connection.source || "");
|
||||||
|
const target = String(connection.target || "");
|
||||||
|
if (!source || !target || source === target) return false;
|
||||||
|
return !edges.some((e) => e.source === source && e.target === target);
|
||||||
|
},
|
||||||
|
[edges],
|
||||||
|
);
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
|
if (!isValidConnection(connection)) return;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
connectClickRef.current = null;
|
connectClickRef.current = null;
|
||||||
const data: EdgeStyleData = {
|
const data: EdgeStyleData = {
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -787,6 +856,7 @@ export function TopologyPage() {
|
||||||
stroke_color: "",
|
stroke_color: "",
|
||||||
stroke_width: 0,
|
stroke_width: 0,
|
||||||
line_style: "",
|
line_style: "",
|
||||||
|
discovered_at: null,
|
||||||
};
|
};
|
||||||
setEdges((eds) =>
|
setEdges((eds) =>
|
||||||
addEdge(
|
addEdge(
|
||||||
|
|
@ -803,7 +873,7 @@ export function TopologyPage() {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[setEdges, pushHistory, edgeDefaults],
|
[setEdges, pushHistory, edgeDefaults, markDirty, isValidConnection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addNodeAt = useCallback(
|
const addNodeAt = useCallback(
|
||||||
|
|
@ -817,7 +887,7 @@ export function TopologyPage() {
|
||||||
const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id);
|
const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id);
|
||||||
if (!ne) return;
|
if (!ne) return;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
setNodes((prev) => [
|
setNodes((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
|
|
@ -841,7 +911,7 @@ export function TopologyPage() {
|
||||||
if (!ne) return;
|
if (!ne) return;
|
||||||
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
|
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
|
||||||
pushHistory();
|
pushHistory();
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
setNodes((prev) => [
|
setNodes((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
|
|
@ -942,12 +1012,14 @@ export function TopologyPage() {
|
||||||
|
|
||||||
const patchSelectedEdgeStyle = useCallback(
|
const patchSelectedEdgeStyle = useCallback(
|
||||||
(
|
(
|
||||||
patch: Partial<Pick<EdgeStyleData, "stroke_color" | "stroke_width" | "line_style">>,
|
patch: Partial<
|
||||||
|
Pick<EdgeStyleData, "stroke_color" | "stroke_width" | "line_style" | "source_port" | "target_port">
|
||||||
|
>,
|
||||||
opts?: { skipHistory?: boolean },
|
opts?: { skipHistory?: boolean },
|
||||||
) => {
|
) => {
|
||||||
if (!selectedEdgeId) return;
|
if (!selectedEdgeId) return;
|
||||||
if (!opts?.skipHistory) pushHistory();
|
if (!opts?.skipHistory) pushHistory();
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
setEdges((eds) =>
|
setEdges((eds) =>
|
||||||
eds.map((e) => {
|
eds.map((e) => {
|
||||||
if (e.id !== selectedEdgeId) return e;
|
if (e.id !== selectedEdgeId) return e;
|
||||||
|
|
@ -958,14 +1030,34 @@ export function TopologyPage() {
|
||||||
stroke_width:
|
stroke_width:
|
||||||
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
|
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 || "",
|
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
|
||||||
|
source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "",
|
||||||
|
target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "",
|
||||||
};
|
};
|
||||||
return withEdgeVisual({ ...e, data }, edgeDefaults);
|
const portLabel = [data.source_port, data.target_port].filter(Boolean).join(" ? ");
|
||||||
|
return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[selectedEdgeId, setEdges, pushHistory, edgeDefaults],
|
[selectedEdgeId, setEdges, pushHistory, edgeDefaults, markDirty],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renameSelectedNode = useCallback(() => {
|
||||||
|
if (!selectedNode) return;
|
||||||
|
const next = window.prompt(t("topology.renameNodePrompt"), selectedNode.data.label || "");
|
||||||
|
if (next == null) return;
|
||||||
|
const label = next.trim();
|
||||||
|
if (!label || label === selectedNode.data.label) {
|
||||||
|
setCtxMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushHistory();
|
||||||
|
markDirty();
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) => (n.id === selectedNode.id ? { ...n, data: { ...n.data, label } } : n)),
|
||||||
|
);
|
||||||
|
setCtxMenu(null);
|
||||||
|
}, [selectedNode, t, pushHistory, markDirty, setNodes]);
|
||||||
|
|
||||||
const staleEdgeCount = useMemo(
|
const staleEdgeCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale")
|
edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale")
|
||||||
|
|
@ -1020,7 +1112,7 @@ export function TopologyPage() {
|
||||||
const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id));
|
const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id));
|
||||||
if (nodeIds.size === 0 && edgeIds.size === 0) return;
|
if (nodeIds.size === 0 && edgeIds.size === 0) return;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
setNodes((ns) => ns.filter((n) => !nodeIds.has(n.id)));
|
setNodes((ns) => ns.filter((n) => !nodeIds.has(n.id)));
|
||||||
setEdges((es) =>
|
setEdges((es) =>
|
||||||
es.filter((e) => !edgeIds.has(e.id) && !nodeIds.has(e.source) && !nodeIds.has(e.target)),
|
es.filter((e) => !edgeIds.has(e.id) && !nodeIds.has(e.source) && !nodeIds.has(e.target)),
|
||||||
|
|
@ -1047,6 +1139,11 @@ export function TopologyPage() {
|
||||||
connectClickRef.current = null;
|
connectClickRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
undo();
|
undo();
|
||||||
|
|
@ -1071,18 +1168,27 @@ export function TopologyPage() {
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges]);
|
}, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ctxMenu) return;
|
if (!ctxMenu) return;
|
||||||
const onScroll = () => closeCtxMenu();
|
const onScroll = () => closeCtxMenu();
|
||||||
|
const onPointerDown = (e: MouseEvent) => {
|
||||||
|
const el = e.target as HTMLElement | null;
|
||||||
|
if (el?.closest?.(".topo-ctx")) return;
|
||||||
|
closeCtxMenu();
|
||||||
|
};
|
||||||
window.addEventListener("scroll", onScroll, true);
|
window.addEventListener("scroll", onScroll, true);
|
||||||
return () => window.removeEventListener("scroll", onScroll, true);
|
window.addEventListener("mousedown", onPointerDown, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
window.removeEventListener("mousedown", onPointerDown, true);
|
||||||
|
};
|
||||||
}, [ctxMenu, closeCtxMenu]);
|
}, [ctxMenu, closeCtxMenu]);
|
||||||
|
|
||||||
const removeNodeById = (nodeId: string) => {
|
const removeNodeById = (nodeId: string) => {
|
||||||
pushHistory();
|
pushHistory();
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
|
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
|
||||||
setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
|
setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
|
|
@ -1090,7 +1196,7 @@ export function TopologyPage() {
|
||||||
|
|
||||||
const removeEdgeById = (edgeId: string) => {
|
const removeEdgeById = (edgeId: string) => {
|
||||||
pushHistory();
|
pushHistory();
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
setEdges((es) => es.filter((e) => e.id !== edgeId));
|
setEdges((es) => es.filter((e) => e.id !== edgeId));
|
||||||
setSelectedEdgeId((cur) => (cur === edgeId ? null : cur));
|
setSelectedEdgeId((cur) => (cur === edgeId ? null : cur));
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
|
|
@ -1100,7 +1206,7 @@ export function TopologyPage() {
|
||||||
const n = staleEdgeCount;
|
const n = staleEdgeCount;
|
||||||
if (n <= 0) return;
|
if (n <= 0) return;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
setEdges((es) =>
|
setEdges((es) =>
|
||||||
es.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") !== "stale"),
|
es.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") !== "stale"),
|
||||||
);
|
);
|
||||||
|
|
@ -1237,7 +1343,7 @@ export function TopologyPage() {
|
||||||
name,
|
name,
|
||||||
ip: ne.ip_address || "",
|
ip: ne.ip_address || "",
|
||||||
vendor: "ZTE",
|
vendor: "ZTE",
|
||||||
meta: `${ne.ip_address || "-"} · ${ne.ne_type || "UME"}`,
|
meta: `${ne.ip_address || "-"} ? ${ne.ne_type || "UME"}`,
|
||||||
connect_status: ne.connection_status || "",
|
connect_status: ne.connection_status || "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -1250,7 +1356,7 @@ export function TopologyPage() {
|
||||||
name: ne.name || ne.ip_address,
|
name: ne.name || ne.ip_address,
|
||||||
ip: ne.ip_address,
|
ip: ne.ip_address,
|
||||||
vendor: ne.vendor,
|
vendor: ne.vendor,
|
||||||
meta: `${ne.ip_address} · ${ne.vendor}`,
|
meta: `${ne.ip_address} ? ${ne.vendor}`,
|
||||||
connect_status: ne.connect_status,
|
connect_status: ne.connect_status,
|
||||||
}));
|
}));
|
||||||
}, [paletteSource, neQuery.data, umeQuery.data]);
|
}, [paletteSource, neQuery.data, umeQuery.data]);
|
||||||
|
|
@ -1291,7 +1397,10 @@ export function TopologyPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm"
|
className="btn btn--sm"
|
||||||
onClick={() => createMapMut.mutate()}
|
onClick={() => {
|
||||||
|
if (!confirmDiscardIfDirty()) return;
|
||||||
|
createMapMut.mutate();
|
||||||
|
}}
|
||||||
disabled={createMapMut.isPending}
|
disabled={createMapMut.isPending}
|
||||||
>
|
>
|
||||||
{t("topology.newMap")}
|
{t("topology.newMap")}
|
||||||
|
|
@ -1317,11 +1426,14 @@ export function TopologyPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="topo-map-list__item"
|
className="topo-map-list__item"
|
||||||
onClick={() => setMapId(m.id)}
|
onClick={() => selectMap(m.id)}
|
||||||
onDoubleClick={() => promptRenameMap(m.id, m.name)}
|
onDoubleClick={() => promptRenameMap(m.id, m.name)}
|
||||||
title={t("topology.renameHint")}
|
title={t("topology.renameHint")}
|
||||||
>
|
>
|
||||||
<span className="topo-map-list__name">{m.name}</span>
|
<span className="topo-map-list__name">
|
||||||
|
{m.name}
|
||||||
|
{mapId === m.id && dirty ? " *" : ""}
|
||||||
|
</span>
|
||||||
<span className="topo-map-list__meta">
|
<span className="topo-map-list__meta">
|
||||||
{m.node_count}N / {m.edge_count}E
|
{m.node_count}N / {m.edge_count}E
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1428,7 +1540,7 @@ export function TopologyPage() {
|
||||||
<span className="topo-palette__name">{item.name}</span>
|
<span className="topo-palette__name">{item.name}</span>
|
||||||
<span className="topo-palette__meta">
|
<span className="topo-palette__meta">
|
||||||
{item.meta}
|
{item.meta}
|
||||||
{onCanvas ? " ✓" : ""}
|
{onCanvas ? " ?" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1457,19 +1569,36 @@ export function TopologyPage() {
|
||||||
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
|
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="topo-toolbar__actions">
|
<div className="topo-toolbar__actions">
|
||||||
<button type="button" className="btn btn--sm btn--ghost" disabled={!mapId} onClick={undo} title="Ctrl+Z">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={!mapId || !canUndo}
|
||||||
|
onClick={undo}
|
||||||
|
title="Ctrl+Z"
|
||||||
|
>
|
||||||
{t("topology.undo")}
|
{t("topology.undo")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn--sm btn--ghost" disabled={!mapId} onClick={redo} title="Ctrl+Y">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={!mapId || !canRedo}
|
||||||
|
onClick={redo}
|
||||||
|
title="Ctrl+Y"
|
||||||
|
>
|
||||||
{t("topology.redo")}
|
{t("topology.redo")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm"
|
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
|
||||||
disabled={!mapId || saveMut.isPending}
|
disabled={!mapId || saveMut.isPending || !dirty}
|
||||||
onClick={() => saveMut.mutate()}
|
onClick={() => saveMut.mutate()}
|
||||||
|
title="Ctrl+S"
|
||||||
>
|
>
|
||||||
{saveMut.isPending ? t("topology.saving") : t("topology.save")}
|
{saveMut.isPending
|
||||||
|
? t("topology.saving")
|
||||||
|
: dirty
|
||||||
|
? t("topology.saveDirty")
|
||||||
|
: t("topology.save")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1686,10 +1815,15 @@ export function TopologyPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm btn--ghost"
|
className="btn btn--sm btn--ghost"
|
||||||
onClick={() => setDiscoverOpen(false)}
|
onClick={() => {
|
||||||
disabled={discovering}
|
if (discovering) {
|
||||||
|
discoverAbortRef.current?.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDiscoverOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("topology.discoverClose")}
|
{discovering ? t("topology.discoverCancel") : t("topology.discoverClose")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{discovering ? (
|
{discovering ? (
|
||||||
|
|
@ -1701,7 +1835,7 @@ export function TopologyPage() {
|
||||||
.replace("{{total}}", String(discoverProgress.total))
|
.replace("{{total}}", String(discoverProgress.total))
|
||||||
.replace(
|
.replace(
|
||||||
"{{name}}",
|
"{{name}}",
|
||||||
discoverProgress.neName || discoverProgress.neIp || "…",
|
discoverProgress.neName || discoverProgress.neIp || "?",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -1727,7 +1861,7 @@ export function TopologyPage() {
|
||||||
<span className="topo-discover__badge">{r.ok ? "OK" : "FAIL"}</span>
|
<span className="topo-discover__badge">{r.ok ? "OK" : "FAIL"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="topo-discover__item-meta">
|
<div className="topo-discover__item-meta">
|
||||||
{r.ne_ip ? `${r.ne_ip} · ` : ""}
|
{r.ne_ip ? `${r.ne_ip} ? ` : ""}
|
||||||
{r.ok
|
{r.ok
|
||||||
? t("topology.discoverNeOk")
|
? t("topology.discoverNeOk")
|
||||||
.replace("{{neighbors}}", String(r.neighbors))
|
.replace("{{neighbors}}", String(r.neighbors))
|
||||||
|
|
@ -1773,17 +1907,18 @@ export function TopologyPage() {
|
||||||
onNodeDragStart={() => pushHistory()}
|
onNodeDragStart={() => pushHistory()}
|
||||||
onNodesChange={(changes) => {
|
onNodesChange={(changes) => {
|
||||||
if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) {
|
if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) {
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
}
|
}
|
||||||
onNodesChange(changes);
|
onNodesChange(changes);
|
||||||
}}
|
}}
|
||||||
onEdgesChange={(changes) => {
|
onEdgesChange={(changes) => {
|
||||||
if (changes.some((c) => c.type !== "select")) {
|
if (changes.some((c) => c.type !== "select")) {
|
||||||
dirtyRef.current = true;
|
markDirty();
|
||||||
}
|
}
|
||||||
onEdgesChange(changes);
|
onEdgesChange(changes);
|
||||||
}}
|
}}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
isValidConnection={isValidConnection}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onEdgeClick={(_e, edge) => {
|
onEdgeClick={(_e, edge) => {
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
|
|
@ -1913,6 +2048,16 @@ export function TopologyPage() {
|
||||||
<li className="topo-ctx__head" role="presentation">
|
<li className="topo-ctx__head" role="presentation">
|
||||||
{t("topology.nodeMenu")}
|
{t("topology.nodeMenu")}
|
||||||
</li>
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-ctx__item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={renameSelectedNode}
|
||||||
|
>
|
||||||
|
{t("topology.renameNode")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li role="none">
|
<li role="none">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1963,6 +2108,32 @@ export function TopologyPage() {
|
||||||
<div className="topo-ctx__style-title">{t("topology.edgeStyle")}</div>
|
<div className="topo-ctx__style-title">{t("topology.edgeStyle")}</div>
|
||||||
{selectedEdgeResolved ? (
|
{selectedEdgeResolved ? (
|
||||||
<div className="topo-ctx__style-grid">
|
<div className="topo-ctx__style-grid">
|
||||||
|
<label className="topo-ctx__style-row">
|
||||||
|
<span>{t("topology.sourcePort")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="topo-ctx__style-input"
|
||||||
|
value={selectedEdgeData.source_port || ""}
|
||||||
|
placeholder="?"
|
||||||
|
onFocus={() => pushHistory()}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchSelectedEdgeStyle({ source_port: e.target.value }, { skipHistory: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="topo-ctx__style-row">
|
||||||
|
<span>{t("topology.targetPort")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="topo-ctx__style-input"
|
||||||
|
value={selectedEdgeData.target_port || ""}
|
||||||
|
placeholder="?"
|
||||||
|
onFocus={() => pushHistory()}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchSelectedEdgeStyle({ target_port: e.target.value }, { skipHistory: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="topo-ctx__style-row">
|
<label className="topo-ctx__style-row">
|
||||||
<span>{t("topology.edgeColor")}</span>
|
<span>{t("topology.edgeColor")}</span>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -572,6 +572,7 @@ export const putTopologyGraph = (
|
||||||
stroke_color?: string;
|
stroke_color?: string;
|
||||||
stroke_width?: number;
|
stroke_width?: number;
|
||||||
line_style?: string;
|
line_style?: string;
|
||||||
|
discovered_at?: string | null;
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
) => apiPut<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body);
|
) => apiPut<TopologyGraph>(`/v1/topology/maps/${encodeURIComponent(mapId)}/graph`, body);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue