mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 17:20:44 +08:00
Make neighbor projection save-deferred and Visio-style connect.
Project-neighbors supports dry_run for local dirty canvas; Save flushes adds. Connect mode uses drag-from side anchors with smoothstep elbows instead of click-click. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a746a84d01
commit
b4f0e468cb
11 changed files with 229 additions and 76 deletions
|
|
@ -244,10 +244,14 @@ class ViewProjectNeighborsRequest(BaseModel):
|
||||||
When seed ids are omitted, expand from every node already on the canvas.
|
When seed ids are omitted, expand from every node already on the canvas.
|
||||||
When provided, expand only from those seeds (resolved to fabric nodes that
|
When provided, expand only from those seeds (resolved to fabric nodes that
|
||||||
are already placed on the view).
|
are already placed on the view).
|
||||||
|
|
||||||
|
dry_run=True: return the would-be graph without writing placements (canvas
|
||||||
|
can apply locally and persist on Save).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
seed_fabric_node_ids: list[str] = Field(default_factory=list)
|
seed_fabric_node_ids: list[str] = Field(default_factory=list)
|
||||||
managed_ne_ids: list[str] = Field(default_factory=list)
|
managed_ne_ids: list[str] = Field(default_factory=list)
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ViewNodeIn(BaseModel):
|
class ViewNodeIn(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -967,10 +967,14 @@ def project_fabric_neighbors_to_view(
|
||||||
|
|
||||||
Optional seeds limit expansion to neighbors of those fabric nodes (must
|
Optional seeds limit expansion to neighbors of those fabric nodes (must
|
||||||
already be on the view). Empty seeds → expand from every canvas node.
|
already be on the view). Empty seeds → expand from every canvas node.
|
||||||
|
|
||||||
|
dry_run=True returns the projected graph without persisting placements.
|
||||||
"""
|
"""
|
||||||
merge_duplicate_fabric_nodes(db)
|
merge_duplicate_fabric_nodes(db)
|
||||||
view = _get_view_or_404(db, view_id)
|
view = _get_view_or_404(db, view_id)
|
||||||
mem = _membership_for_view(view)
|
mem = _membership_for_view(view)
|
||||||
|
req = body or ViewProjectNeighborsRequest()
|
||||||
|
dry_run = bool(req.dry_run)
|
||||||
if bool(mem.get("frozen")):
|
if bool(mem.get("frozen")):
|
||||||
g = get_view_graph(db, view.id)
|
g = get_view_graph(db, view.id)
|
||||||
g.truncated = True
|
g.truncated = True
|
||||||
|
|
@ -981,7 +985,6 @@ def project_fabric_neighbors_to_view(
|
||||||
hops = int(mem.get("expand_hops") or 1)
|
hops = int(mem.get("expand_hops") or 1)
|
||||||
filt = dict(view.filter or {})
|
filt = dict(view.filter or {})
|
||||||
layer = str(filt.get("layer") or "physical").strip() or "physical"
|
layer = str(filt.get("layer") or "physical").strip() or "physical"
|
||||||
req = body or ViewProjectNeighborsRequest()
|
|
||||||
|
|
||||||
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||||
fids_on_view = [vn.fabric_node_id for vn in vnodes]
|
fids_on_view = [vn.fabric_node_id for vn in vnodes]
|
||||||
|
|
@ -995,7 +998,7 @@ def project_fabric_neighbors_to_view(
|
||||||
)
|
)
|
||||||
# Drop placements pointing at missing fabric rows only (keep LLDP placeholders).
|
# Drop placements pointing at missing fabric rows only (keep LLDP placeholders).
|
||||||
orphan_vns = [vn for vn in vnodes if vn.fabric_node_id not in fabric_on_view]
|
orphan_vns = [vn for vn in vnodes if vn.fabric_node_id not in fabric_on_view]
|
||||||
if orphan_vns:
|
if orphan_vns and not dry_run:
|
||||||
for vn in orphan_vns:
|
for vn in orphan_vns:
|
||||||
db.delete(vn)
|
db.delete(vn)
|
||||||
view.updated_at = _utcnow()
|
view.updated_at = _utcnow()
|
||||||
|
|
@ -1064,6 +1067,25 @@ def project_fabric_neighbors_to_view(
|
||||||
room = max(0, max_nodes - len(existing))
|
room = max(0, max_nodes - len(existing))
|
||||||
to_add = eligible[:room]
|
to_add = eligible[:room]
|
||||||
truncated = len(eligible) > len(to_add)
|
truncated = len(eligible) > len(to_add)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
if to_add:
|
||||||
|
nested = db.begin_nested()
|
||||||
|
try:
|
||||||
|
_place_fabric_ids_on_view(
|
||||||
|
db, view, to_add, existing=set(existing), near_fabric_ids=seed_ids
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
g = get_view_graph(db, view.id)
|
||||||
|
finally:
|
||||||
|
nested.rollback()
|
||||||
|
else:
|
||||||
|
g = get_view_graph(db, view.id)
|
||||||
|
if truncated:
|
||||||
|
g.truncated = True
|
||||||
|
g.truncate_reason = g.truncate_reason or "membership_cap"
|
||||||
|
return g
|
||||||
|
|
||||||
if to_add:
|
if to_add:
|
||||||
_place_fabric_ids_on_view(
|
_place_fabric_ids_on_view(
|
||||||
db, view, to_add, existing=existing, near_fabric_ids=seed_ids
|
db, view, to_add, existing=existing, near_fabric_ids=seed_ids
|
||||||
|
|
|
||||||
|
|
@ -724,6 +724,58 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
self.assertLessEqual(pop.candidate_count, 4)
|
self.assertLessEqual(pop.candidate_count, 4)
|
||||||
self.assertTrue(pop.truncated or pop.candidate_count <= 2 or pop.would_add <= 2)
|
self.assertTrue(pop.truncated or pop.candidate_count <= 2 or pop.would_add <= 2)
|
||||||
|
|
||||||
|
def test_project_neighbors_dry_run_does_not_persist(self) -> None:
|
||||||
|
suffix = uuid4().hex[:8]
|
||||||
|
nes = []
|
||||||
|
for i in range(3):
|
||||||
|
ne = ManagedNE(
|
||||||
|
id=f"dry-{suffix}-{i}",
|
||||||
|
name=f"D{i}-{suffix}",
|
||||||
|
vendor="Cisco",
|
||||||
|
device_type="cisco_ios",
|
||||||
|
ip_address=f"10.19.{(int(suffix[:2], 16) % 200)}.{i + 1}",
|
||||||
|
)
|
||||||
|
self.db.add(ne)
|
||||||
|
nes.append(ne)
|
||||||
|
self.db.commit()
|
||||||
|
nodes = [svc.ensure_fabric_node_for_managed(self.db, ne) for ne in nes]
|
||||||
|
self.db.commit()
|
||||||
|
for a, b in ((0, 1), (1, 2)):
|
||||||
|
svc.upsert_fabric_edge(
|
||||||
|
self.db,
|
||||||
|
a_node_id=nodes[a].id,
|
||||||
|
b_node_id=nodes[b].id,
|
||||||
|
a_port=f"Gi0/{a}",
|
||||||
|
b_port=f"Gi0/{b}",
|
||||||
|
source="lldp",
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
view = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(
|
||||||
|
name=f"Dry-{suffix}",
|
||||||
|
folder_id=self._region(f"DryR-{suffix}"),
|
||||||
|
role="core",
|
||||||
|
filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
svc.add_nodes_to_view(self.db, view.id, ViewNodesAdd(managed_ne_ids=[nes[0].id]))
|
||||||
|
before = (
|
||||||
|
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).count()
|
||||||
|
)
|
||||||
|
g = svc.project_fabric_neighbors_to_view(
|
||||||
|
self.db,
|
||||||
|
view.id,
|
||||||
|
ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id], dry_run=True),
|
||||||
|
)
|
||||||
|
ids = {n.fabric_node_id for n in g.nodes}
|
||||||
|
self.assertIn(nodes[0].id, ids)
|
||||||
|
self.assertIn(nodes[1].id, ids)
|
||||||
|
after = (
|
||||||
|
self.db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).count()
|
||||||
|
)
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
|
||||||
def test_lldp_discover_writes_fabric_edge(self) -> None:
|
def test_lldp_discover_writes_fabric_edge(self) -> None:
|
||||||
suffix = uuid4().hex[:8]
|
suffix = uuid4().hex[:8]
|
||||||
ne_a = ManagedNE(
|
ne_a = ManagedNE(
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,8 @@ src/
|
||||||
- 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
|
- 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
|
||||||
- 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」)
|
- 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」)
|
||||||
- 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建区域后需手工/MCP 建画布)+ `topo_view_node`
|
- 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建区域后需手工/MCP 建画布)+ `topo_view_node`
|
||||||
- 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网
|
- 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网。画布「投影邻居 / 发现后投影」默认 `dry_run`:只进本地脏图,**保存**时 `addTopologyViewNodes` 才落库。
|
||||||
|
- 连线模式:Visio 式四边锚点拖拽连线(非点选两点);默认折线 `smoothstep`。
|
||||||
- API:`/v1/topology/tree`、`/folders*`、`/fabric/*`、`/views*`(含 `populate`、`kind`)
|
- API:`/v1/topology/tree`、`/folders*`、`/fabric/*`、`/views*`(含 `populate`、`kind`)
|
||||||
- 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」支持勾选批量上图(也可拖放单台)
|
- 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」支持勾选批量上图(也可拖放单台)
|
||||||
- MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web
|
- MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web
|
||||||
|
|
|
||||||
|
|
@ -1493,7 +1493,8 @@ const en = {
|
||||||
snapGrid: "Snap to grid",
|
snapGrid: "Snap to grid",
|
||||||
autoLayoutDiscover: "Auto-layout after discover",
|
autoLayoutDiscover: "Auto-layout after discover",
|
||||||
discoverAutoAddUnmatched: "Auto-add unmatched NEs as placeholders on discover",
|
discoverAutoAddUnmatched: "Auto-add unmatched NEs as placeholders on discover",
|
||||||
discoverProjectNeighbors: "After discover, project neighbors of scanned NEs onto the canvas",
|
discoverProjectNeighbors:
|
||||||
|
"After discover, project neighbors of scanned NEs onto the canvas (Save to keep permanently)",
|
||||||
undo: "Undo",
|
undo: "Undo",
|
||||||
redo: "Redo",
|
redo: "Redo",
|
||||||
discoverSelected: "Discover selected ({{count}})",
|
discoverSelected: "Discover selected ({{count}})",
|
||||||
|
|
@ -1621,7 +1622,7 @@ const en = {
|
||||||
removeStaleHint:
|
removeStaleHint:
|
||||||
"Queue delete of red missing edges (applies on Save); scheduled collect also purges after 4 consecutive misses",
|
"Queue delete of red missing edges (applies on Save); scheduled collect also purges after 4 consecutive misses",
|
||||||
staleRemoved: "Queued {{count}} missing edge delete(s) — Save to apply",
|
staleRemoved: "Queued {{count}} missing edge delete(s) — Save to apply",
|
||||||
connectHint: "Connect mode: click source node, then target (Esc or Select to exit)",
|
connectHint: "Connect mode: drag from a node's side anchors to the target NE (Esc or Select to exit)",
|
||||||
},
|
},
|
||||||
topoClassify: {
|
topoClassify: {
|
||||||
title: "Classify & slices",
|
title: "Classify & slices",
|
||||||
|
|
|
||||||
|
|
@ -1484,7 +1484,7 @@ const zh = {
|
||||||
snapGrid: "网格吸附",
|
snapGrid: "网格吸附",
|
||||||
autoLayoutDiscover: "发现后自动布局",
|
autoLayoutDiscover: "发现后自动布局",
|
||||||
discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)",
|
discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)",
|
||||||
discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布",
|
discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布(需保存才永久保留)",
|
||||||
undo: "撤销",
|
undo: "撤销",
|
||||||
redo: "重做",
|
redo: "重做",
|
||||||
discoverSelected: "发现选中 ({{count}})",
|
discoverSelected: "发现选中 ({{count}})",
|
||||||
|
|
@ -1608,7 +1608,7 @@ const zh = {
|
||||||
removeStale: "清除未发现 ({{count}})",
|
removeStale: "清除未发现 ({{count}})",
|
||||||
removeStaleHint: "标记删除红色未发现链路(保存后生效);周期采集连续 4 次未发现也会自动清理",
|
removeStaleHint: "标记删除红色未发现链路(保存后生效);周期采集连续 4 次未发现也会自动清理",
|
||||||
staleRemoved: "已标记删除 {{count}} 条未发现链路(保存后生效)",
|
staleRemoved: "已标记删除 {{count}} 条未发现链路(保存后生效)",
|
||||||
connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)",
|
connectHint: "连线模式:从节点四边锚点拖到目标网元(Esc 或切回选择退出)",
|
||||||
},
|
},
|
||||||
topoClassify: {
|
topoClassify: {
|
||||||
title: "分类与切片",
|
title: "分类与切片",
|
||||||
|
|
|
||||||
|
|
@ -7042,16 +7042,76 @@ pre {
|
||||||
|
|
||||||
.topo-node.is-connect-mode .topo-node__handle {
|
.topo-node.is-connect-mode .topo-node__handle {
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
|
opacity: 1 !important;
|
||||||
|
width: 12px !important;
|
||||||
|
height: 12px !important;
|
||||||
|
min-width: 12px !important;
|
||||||
|
min-height: 12px !important;
|
||||||
|
background: #f8fafc !important;
|
||||||
|
border: 2px solid #38bdf8 !important;
|
||||||
|
border-radius: 2px !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.35);
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-node__handle--center {
|
.topo-node.is-connect-mode .topo-node__handle:hover {
|
||||||
|
background: #38bdf8 !important;
|
||||||
|
border-color: #e0f2fe !important;
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__handle--n {
|
||||||
left: 50% !important;
|
left: 50% !important;
|
||||||
|
top: 0 !important;
|
||||||
|
right: auto !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
transform: translate(-50%, -50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__handle--e {
|
||||||
|
left: auto !important;
|
||||||
|
top: 50% !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
transform: translate(50%, -50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__handle--s {
|
||||||
|
left: 50% !important;
|
||||||
|
top: auto !important;
|
||||||
|
right: auto !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
transform: translate(-50%, 50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__handle--w {
|
||||||
|
left: 0 !important;
|
||||||
top: 50% !important;
|
top: 50% !important;
|
||||||
right: auto !important;
|
right: auto !important;
|
||||||
bottom: auto !important;
|
bottom: auto !important;
|
||||||
transform: translate(-50%, -50%) !important;
|
transform: translate(-50%, -50%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-node.is-connect-mode .topo-node__handle--n:hover,
|
||||||
|
.topo-node.is-connect-mode .topo-node__handle--e:hover,
|
||||||
|
.topo-node.is-connect-mode .topo-node__handle--s:hover,
|
||||||
|
.topo-node.is-connect-mode .topo-node__handle--w:hover {
|
||||||
|
/* keep side translate while scaling */
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node.is-connect-mode .topo-node__handle--n:hover {
|
||||||
|
transform: translate(-50%, -50%) scale(1.15) !important;
|
||||||
|
}
|
||||||
|
.topo-node.is-connect-mode .topo-node__handle--e:hover {
|
||||||
|
transform: translate(50%, -50%) scale(1.15) !important;
|
||||||
|
}
|
||||||
|
.topo-node.is-connect-mode .topo-node__handle--s:hover {
|
||||||
|
transform: translate(-50%, 50%) scale(1.15) !important;
|
||||||
|
}
|
||||||
|
.topo-node.is-connect-mode .topo-node__handle--w:hover {
|
||||||
|
transform: translate(-50%, -50%) scale(1.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-node.is-selected .topo-node__glyph {
|
.topo-node.is-selected .topo-node__glyph {
|
||||||
outline: 2px solid #60a5fa;
|
outline: 2px solid #60a5fa;
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
useNodesState,
|
useNodesState,
|
||||||
Handle,
|
Handle,
|
||||||
Position,
|
Position,
|
||||||
|
ConnectionLineType,
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
SelectionMode,
|
SelectionMode,
|
||||||
type Connection,
|
type Connection,
|
||||||
|
|
@ -430,6 +431,12 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
||||||
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
|
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
|
||||||
hideVendor || !data.vendor ? "" : data.vendor,
|
hideVendor || !data.vendor ? "" : data.vendor,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
const handlePositions = [
|
||||||
|
{ id: "n", position: Position.Top },
|
||||||
|
{ id: "e", position: Position.Right },
|
||||||
|
{ id: "s", position: Position.Bottom },
|
||||||
|
{ id: "w", position: Position.Left },
|
||||||
|
] as const;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
|
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
|
||||||
|
|
@ -438,18 +445,16 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
||||||
title={showBadge ? data.managed_source || "placeholder" : undefined}
|
title={showBadge ? data.managed_source || "placeholder" : undefined}
|
||||||
>
|
>
|
||||||
<div className="topo-node__glyph">
|
<div className="topo-node__glyph">
|
||||||
|
{handlePositions.map(({ id, position }) => (
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
key={id}
|
||||||
position={Position.Left}
|
id={id}
|
||||||
className="topo-node__handle topo-node__handle--center"
|
|
||||||
isConnectable={connectMode}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={position}
|
||||||
className="topo-node__handle topo-node__handle--center"
|
className={`topo-node__handle topo-node__handle--${id}`}
|
||||||
isConnectable={connectMode}
|
isConnectable={connectMode}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
<RouterIcon />
|
<RouterIcon />
|
||||||
{showBadge ? (
|
{showBadge ? (
|
||||||
<span className="topo-node__badge" aria-hidden>
|
<span className="topo-node__badge" aria-hidden>
|
||||||
|
|
@ -738,11 +743,13 @@ function graphToFlow(
|
||||||
position: { x: n.x || 0, y: n.y || 0 },
|
position: { x: n.x || 0, y: n.y || 0 },
|
||||||
width: TOPO_NODE_W,
|
width: TOPO_NODE_W,
|
||||||
height: TOPO_NODE_H,
|
height: TOPO_NODE_H,
|
||||||
// Predetermined handles must match DOM anchors (icon center), not the 160px box edges —
|
// Predetermined handles must match DOM anchors on the router glyph (56×56, top-centered).
|
||||||
// otherwise edges float in the gap and never touch the router glyph.
|
// ConnectionMode.Loose treats these source handles as connectable targets too.
|
||||||
handles: [
|
handles: [
|
||||||
{ type: "target", position: Position.Left, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y },
|
{ type: "source", position: Position.Top, id: "n", x: TOPO_HANDLE_X, y: 0 },
|
||||||
{ type: "source", position: Position.Right, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y },
|
{ type: "source", position: Position.Right, id: "e", x: TOPO_HANDLE_X + TOPO_ICON / 2, y: TOPO_HANDLE_Y },
|
||||||
|
{ type: "source", position: Position.Bottom, id: "s", x: TOPO_HANDLE_X, y: TOPO_ICON },
|
||||||
|
{ type: "source", position: Position.Left, id: "w", x: TOPO_HANDLE_X - TOPO_ICON / 2, y: TOPO_HANDLE_Y },
|
||||||
],
|
],
|
||||||
data: {
|
data: {
|
||||||
label: n.label || n.name || n.ip || n.fabric_node_id,
|
label: n.label || n.name || n.ip || n.fabric_node_id,
|
||||||
|
|
@ -772,7 +779,7 @@ function graphToFlow(
|
||||||
id: e.id,
|
id: e.id,
|
||||||
source: e.a_node_id,
|
source: e.a_node_id,
|
||||||
target: e.b_node_id,
|
target: e.b_node_id,
|
||||||
type: "straight",
|
type: "smoothstep",
|
||||||
label: label || undefined,
|
label: label || undefined,
|
||||||
animated: false,
|
animated: false,
|
||||||
data,
|
data,
|
||||||
|
|
@ -1536,12 +1543,18 @@ export function TopologyPage() {
|
||||||
await deleteFabricEdges(pendingEdges);
|
await deleteFabricEdges(pendingEdges);
|
||||||
pendingEdgeDeletesRef.current.clear();
|
pendingEdgeDeletesRef.current.clear();
|
||||||
}
|
}
|
||||||
// Positions patch only updates listed nodes; sync canvas removals first.
|
|
||||||
const serverIds = (graphQuery.data?.nodes || [])
|
const serverIds = (graphQuery.data?.nodes || [])
|
||||||
.map((n) => n.fabric_node_id)
|
.map((n) => n.fabric_node_id)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const localIds = new Set(nodes.map((n) => n.id));
|
const localIds = nodes.map((n) => n.id);
|
||||||
const toRemove = serverIds.filter((id) => !localIds.has(id));
|
const localSet = new Set(localIds);
|
||||||
|
const serverSet = new Set(serverIds);
|
||||||
|
const toAdd = localIds.filter((id) => !serverSet.has(id));
|
||||||
|
const toRemove = serverIds.filter((id) => !localSet.has(id));
|
||||||
|
// Add first so a save that both adds and removes never flashes an empty view.
|
||||||
|
if (toAdd.length) {
|
||||||
|
await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd });
|
||||||
|
}
|
||||||
if (toRemove.length) {
|
if (toRemove.length) {
|
||||||
await removeTopologyViewNodes(mapId, toRemove);
|
await removeTopologyViewNodes(mapId, toRemove);
|
||||||
}
|
}
|
||||||
|
|
@ -1596,8 +1609,14 @@ export function TopologyPage() {
|
||||||
const serverIds = (graphQuery.data?.nodes || [])
|
const serverIds = (graphQuery.data?.nodes || [])
|
||||||
.map((n) => n.fabric_node_id)
|
.map((n) => n.fabric_node_id)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const localIds = new Set(nodes.map((n) => n.id));
|
const localIds = nodes.map((n) => n.id);
|
||||||
const toRemove = serverIds.filter((id) => !localIds.has(id));
|
const localSet = new Set(localIds);
|
||||||
|
const serverSet = new Set(serverIds);
|
||||||
|
const toAdd = localIds.filter((id) => !serverSet.has(id));
|
||||||
|
const toRemove = serverIds.filter((id) => !localSet.has(id));
|
||||||
|
if (toAdd.length) {
|
||||||
|
await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd });
|
||||||
|
}
|
||||||
if (toRemove.length) {
|
if (toRemove.length) {
|
||||||
await removeTopologyViewNodes(mapId, toRemove);
|
await removeTopologyViewNodes(mapId, toRemove);
|
||||||
}
|
}
|
||||||
|
|
@ -1673,12 +1692,16 @@ export function TopologyPage() {
|
||||||
} catch {
|
} catch {
|
||||||
/* keep last polled job */
|
/* keep last polled job */
|
||||||
}
|
}
|
||||||
|
// Refresh base graph from server (LLDP may have new edges), then dry-run project.
|
||||||
|
const baseGraph = await fetchTopologyGraph(mapId);
|
||||||
const projected = discoverProjectNeighbors
|
const projected = discoverProjectNeighbors
|
||||||
? await projectTopologyNeighbors(mapId, {
|
? await projectTopologyNeighbors(mapId, {
|
||||||
seed_fabric_node_ids: scoped.map((n) => n.id),
|
seed_fabric_node_ids: scoped.map((n) => n.id),
|
||||||
|
dry_run: true,
|
||||||
})
|
})
|
||||||
: await fetchTopologyGraph(mapId);
|
: baseGraph;
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
|
// Keep server query as committed graph (dry_run must not poison cache as persisted).
|
||||||
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), baseGraph);
|
||||||
if (discoverProjectNeighbors && projected.truncate_reason === "membership_frozen") {
|
if (discoverProjectNeighbors && projected.truncate_reason === "membership_frozen") {
|
||||||
showError(t("topology.truncatedFrozen"));
|
showError(t("topology.truncatedFrozen"));
|
||||||
}
|
}
|
||||||
|
|
@ -1687,6 +1710,7 @@ export function TopologyPage() {
|
||||||
// Keep existing node positions when we did not auto-layout.
|
// Keep existing node positions when we did not auto-layout.
|
||||||
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
||||||
const beforeIds = new Set(nodes.map((n) => n.id));
|
const beforeIds = new Set(nodes.map((n) => n.id));
|
||||||
|
const serverNodeIds = new Set((baseGraph.nodes || []).map((n) => n.fabric_node_id));
|
||||||
rfNodes = rfNodes.map((n) => {
|
rfNodes = rfNodes.map((n) => {
|
||||||
const p = localPos.get(n.id);
|
const p = localPos.get(n.id);
|
||||||
return p ? { ...n, position: { ...p } } : n;
|
return p ? { ...n, position: { ...p } } : n;
|
||||||
|
|
@ -1695,21 +1719,18 @@ export function TopologyPage() {
|
||||||
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
||||||
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||||
didAutoLayout = true;
|
didAutoLayout = true;
|
||||||
try {
|
|
||||||
const graph = await patchTopologyPositions(mapId, flowToPositions(rfNodes));
|
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
|
||||||
clearDirty();
|
|
||||||
} catch {
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clearDirty();
|
|
||||||
}
|
}
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
setNodes(rfNodes);
|
setNodes(rfNodes);
|
||||||
setEdges(rfEdges);
|
setEdges(rfEdges);
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
const addedNodes = rfNodes.filter((n) => !beforeIds.has(n.id)).length;
|
const addedNodes = rfNodes.filter((n) => !beforeIds.has(n.id)).length;
|
||||||
|
const localOnly = rfNodes.some((n) => !serverNodeIds.has(n.id));
|
||||||
|
if (localOnly || didAutoLayout) {
|
||||||
|
markDirty();
|
||||||
|
} else {
|
||||||
|
clearDirty();
|
||||||
|
}
|
||||||
if (didAutoLayout || addedNodes > 0) {
|
if (didAutoLayout || addedNodes > 0) {
|
||||||
needsInitialFitRef.current = true;
|
needsInitialFitRef.current = true;
|
||||||
scheduleFitView(FIT_VIEW_OPTS);
|
scheduleFitView(FIT_VIEW_OPTS);
|
||||||
|
|
@ -2195,35 +2216,32 @@ export function TopologyPage() {
|
||||||
if (!mapId) return;
|
if (!mapId) return;
|
||||||
try {
|
try {
|
||||||
const before = nodes.length;
|
const before = nodes.length;
|
||||||
const projected = await projectTopologyNeighbors(mapId);
|
const projected = await projectTopologyNeighbors(mapId, { dry_run: true });
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
|
|
||||||
appliedMapIdRef.current = mapId;
|
|
||||||
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
|
||||||
historyLockRef.current = true;
|
|
||||||
applyViewGraph(projected, edgeDefaults, setNodes, setEdges, localPos);
|
|
||||||
historyLockRef.current = false;
|
|
||||||
clearDirty();
|
|
||||||
if (projected.truncate_reason === "membership_frozen") {
|
if (projected.truncate_reason === "membership_frozen") {
|
||||||
showError(t("topology.truncatedFrozen"));
|
showError(t("topology.truncatedFrozen"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
||||||
|
historyLockRef.current = true;
|
||||||
|
applyViewGraph(projected, edgeDefaults, setNodes, setEdges, localPos);
|
||||||
|
historyLockRef.current = false;
|
||||||
const added = Math.max(0, projected.nodes.length - before);
|
const added = Math.max(0, projected.nodes.length - before);
|
||||||
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(added)));
|
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
|
markDirty();
|
||||||
needsInitialFitRef.current = true;
|
needsInitialFitRef.current = true;
|
||||||
scheduleFitView(FIT_VIEW_OPTS);
|
scheduleFitView(FIT_VIEW_OPTS);
|
||||||
}
|
}
|
||||||
|
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(added)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(err));
|
showError(String(err));
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
mapId,
|
mapId,
|
||||||
nodes,
|
nodes,
|
||||||
queryClient,
|
|
||||||
edgeDefaults,
|
edgeDefaults,
|
||||||
setNodes,
|
setNodes,
|
||||||
setEdges,
|
setEdges,
|
||||||
clearDirty,
|
markDirty,
|
||||||
showOk,
|
showOk,
|
||||||
showError,
|
showError,
|
||||||
t,
|
t,
|
||||||
|
|
@ -2769,22 +2787,11 @@ export function TopologyPage() {
|
||||||
const onNodeClick = useCallback(
|
const onNodeClick = useCallback(
|
||||||
(e: React.MouseEvent, node: Node<NeNodeData>) => {
|
(e: React.MouseEvent, node: Node<NeNodeData>) => {
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
if (toolMode === "connect") {
|
// Connect mode uses Visio-style drag from handles (onConnect), not click-click.
|
||||||
const prev = connectClickRef.current;
|
if (toolMode === "connect") return;
|
||||||
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);
|
focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey);
|
||||||
},
|
},
|
||||||
[toolMode, focusNode, onConnect],
|
[toolMode, focusNode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const outsidePeers = graphQuery.data?.outside_peers || [];
|
const outsidePeers = graphQuery.data?.outside_peers || [];
|
||||||
|
|
@ -3478,7 +3485,6 @@ export function TopologyPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{t("topology.discoverProjectNeighbors")}
|
{t("topology.discoverProjectNeighbors")}
|
||||||
<span className="muted"> ({t("topology.localBrowserOnly")})</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
|
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
|
||||||
<div className="topo-display-defaults__head">
|
<div className="topo-display-defaults__head">
|
||||||
|
|
@ -4131,7 +4137,9 @@ export function TopologyPage() {
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
onlyRenderVisibleElements
|
onlyRenderVisibleElements
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
defaultEdgeOptions={{ type: "straight", labelShowBg: false }}
|
connectionLineType={ConnectionLineType.SmoothStep}
|
||||||
|
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }}
|
||||||
|
defaultEdgeOptions={{ type: "smoothstep", labelShowBg: false }}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
minZoom={0.05}
|
minZoom={0.05}
|
||||||
maxZoom={4}
|
maxZoom={4}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getStraightPath } from "@xyflow/react";
|
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from "@xyflow/react";
|
||||||
|
|
||||||
/** Offset parallel physical links so they do not stack on one line. */
|
/** Offset parallel physical links so they do not stack on one line (Visio-like elbows). */
|
||||||
export function ParallelEdge({
|
export function ParallelEdge({
|
||||||
id,
|
id,
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
style,
|
style,
|
||||||
markerEnd,
|
markerEnd,
|
||||||
label,
|
label,
|
||||||
|
|
@ -16,17 +18,20 @@ export function ParallelEdge({
|
||||||
}: EdgeProps) {
|
}: EdgeProps) {
|
||||||
const index = Number((data as { parallelIndex?: number } | undefined)?.parallelIndex || 0);
|
const index = Number((data as { parallelIndex?: number } | undefined)?.parallelIndex || 0);
|
||||||
const count = Math.max(1, Number((data as { parallelCount?: number } | undefined)?.parallelCount || 1));
|
const count = Math.max(1, Number((data as { parallelCount?: number } | undefined)?.parallelCount || 1));
|
||||||
const offset = (index - (count - 1) / 2) * 10;
|
const offset = (index - (count - 1) / 2) * 12;
|
||||||
const dx = targetX - sourceX;
|
const dx = targetX - sourceX;
|
||||||
const dy = targetY - sourceY;
|
const dy = targetY - sourceY;
|
||||||
const len = Math.hypot(dx, dy) || 1;
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
const ox = (-dy / len) * offset;
|
const ox = (-dy / len) * offset;
|
||||||
const oy = (dx / len) * offset;
|
const oy = (dx / len) * offset;
|
||||||
const [path, labelX, labelY] = getStraightPath({
|
const [path, labelX, labelY] = getSmoothStepPath({
|
||||||
sourceX: sourceX + ox,
|
sourceX: sourceX + ox,
|
||||||
sourceY: sourceY + oy,
|
sourceY: sourceY + oy,
|
||||||
targetX: targetX + ox,
|
targetX: targetX + ox,
|
||||||
targetY: targetY + oy,
|
targetY: targetY + oy,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
borderRadius: 8,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export function buildLinkDisplayEdges(
|
||||||
const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
|
const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
|
||||||
out.push({
|
out.push({
|
||||||
...e,
|
...e,
|
||||||
type: count > 1 ? "topoParallel" : e.type || "straight",
|
type: count > 1 ? "topoParallel" : e.type || "smoothstep",
|
||||||
// Never fall back to a stale e.label — it may still contain ports.
|
// Never fall back to a stale e.label — it may still contain ports.
|
||||||
label: hidePorts ? undefined : portLabel || undefined,
|
label: hidePorts ? undefined : portLabel || undefined,
|
||||||
data,
|
data,
|
||||||
|
|
@ -137,7 +137,7 @@ export function buildLinkDisplayEdges(
|
||||||
const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
|
const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
|
||||||
out.push({
|
out.push({
|
||||||
...e,
|
...e,
|
||||||
type: "straight",
|
type: "smoothstep",
|
||||||
label: hidePorts ? undefined : portLabel || undefined,
|
label: hidePorts ? undefined : portLabel || undefined,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
@ -162,7 +162,7 @@ export function buildLinkDisplayEdges(
|
||||||
out.push({
|
out.push({
|
||||||
...primary,
|
...primary,
|
||||||
id: aggregateIdForPair(primary.source, primary.target),
|
id: aggregateIdForPair(primary.source, primary.target),
|
||||||
type: "straight",
|
type: "smoothstep",
|
||||||
label: formatBundleLabel(
|
label: formatBundleLabel(
|
||||||
list.length,
|
list.length,
|
||||||
{ a_port: data.source_port, b_port: data.target_port },
|
{ a_port: data.source_port, b_port: data.target_port },
|
||||||
|
|
|
||||||
|
|
@ -1086,7 +1086,7 @@ export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[])
|
||||||
|
|
||||||
export const projectTopologyNeighbors = (
|
export const projectTopologyNeighbors = (
|
||||||
viewId: string,
|
viewId: string,
|
||||||
body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[] },
|
body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[]; dry_run?: boolean },
|
||||||
) =>
|
) =>
|
||||||
apiPost<TopologyViewGraph>(
|
apiPost<TopologyViewGraph>(
|
||||||
`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,
|
`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue