mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Defer manual canvas edges to Save and restore center-anchor connect.
Keep connect/delete/move as dirty local edits until Save, use straight edges again, and drop Visio side handles so lines attach at the NE center like discovered links. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b4f0e468cb
commit
155cb4e921
7 changed files with 180 additions and 140 deletions
|
|
@ -127,12 +127,12 @@ src/
|
||||||
|
|
||||||
- 三层库存:**运维** `managed_ne`(凭据/采集/WebCRT)· **EMS** `ume_inventory_ne` · **拓扑投影** `topo_fabric_node`(上图/LLDP/分类)。Fabric 由 ensure 按需创建,与运维表不是同一张表。
|
- 三层库存:**运维** `managed_ne`(凭据/采集/WebCRT)· **EMS** `ume_inventory_ne` · **拓扑投影** `topo_fabric_node`(上图/LLDP/分类)。Fabric 由 ensure 按需创建,与运维表不是同一张表。
|
||||||
- 删除运维网元(手工 / WebCRT / `ume_sync` ManagedNE 同一路径):先解绑 `fabric.managed_ne_id`(及 view membership)。若该 Fabric 节点同时仍绑 UME → 变为 UME-only,**保留**节点与直连边;若解绑后已无 managed 且无 UME(完全孤儿)→ **硬删** Fabric 节点并级联直连边 / placements。UME 库存 reconcile 对称解绑 `ume_ne_id`,同样在完全孤儿时 purge。历史悬空引用与孤儿 GC:`POST /fabric/reconcile-links`(`cleanup-duplicates` 先 merge/absorb 再扫孤儿);后台默认每 6h 跑一次(`fabric_reconcile_scheduler_*`,可关)。
|
- 删除运维网元(手工 / WebCRT / `ume_sync` ManagedNE 同一路径):先解绑 `fabric.managed_ne_id`(及 view membership)。若该 Fabric 节点同时仍绑 UME → 变为 UME-only,**保留**节点与直连边;若解绑后已无 managed 且无 UME(完全孤儿)→ **硬删** Fabric 节点并级联直连边 / placements。UME 库存 reconcile 对称解绑 `ume_ne_id`,同样在完全孤儿时 purge。历史悬空引用与孤儿 GC:`POST /fabric/reconcile-links`(`cleanup-duplicates` 先 merge/absorb 再扫孤儿);后台默认每 6h 跑一次(`fabric_reconcile_scheduler_*`,可关)。
|
||||||
- 画布:移出本图仅影响 placement(保存落库);删边本地排队、**保存**才删 Fabric(Undo 仅保存前有效);topology/lldp 占位可右键硬删(ManagedNE+Fabric+边)。未匹配且未建占位的对端不上 Fabric 边。
|
- 画布:移出本图仅影响 placement(保存落库);连线/删边本地排队、**保存**才写/删 Fabric(Undo 仅保存前有效);topology/lldp 占位可右键硬删(ManagedNE+Fabric+边)。未匹配且未建占位的对端不上 Fabric 边。
|
||||||
- 事实库:`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` 不得无界灌全网。画布「投影邻居 / 发现后投影」默认 `dry_run`:只进本地脏图,**保存**时 `addTopologyViewNodes` 才落库。
|
- 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网。画布「投影邻居 / 发现后投影」默认 `dry_run`:只进本地脏图,**保存**时 `addTopologyViewNodes` 才落库。
|
||||||
- 连线模式:Visio 式四边锚点拖拽连线(非点选两点);默认折线 `smoothstep`。
|
- 连线模式:网元中心锚点拖拽连线;默认直线 `straight`(与发现边同一附着点)。
|
||||||
- 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
|
||||||
|
|
|
||||||
|
|
@ -1622,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: drag from a node's side anchors to the target NE (Esc or Select to exit)",
|
connectHint: "Connect mode: drag from the NE center anchor to the target (Esc or Select to exit)",
|
||||||
},
|
},
|
||||||
topoClassify: {
|
topoClassify: {
|
||||||
title: "Classify & slices",
|
title: "Classify & slices",
|
||||||
|
|
|
||||||
|
|
@ -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: "分类与切片",
|
||||||
|
|
|
||||||
|
|
@ -6339,11 +6339,15 @@ pre {
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-node.is-connect-mode .topo-node__handle {
|
.topo-node.is-connect-mode .topo-node__handle {
|
||||||
width: 12px !important;
|
width: 28px !important;
|
||||||
height: 12px !important;
|
height: 28px !important;
|
||||||
opacity: 1 !important;
|
min-width: 28px !important;
|
||||||
background: #0ea5e9 !important;
|
min-height: 28px !important;
|
||||||
border: 2px solid #fff !important;
|
opacity: 0.9 !important;
|
||||||
|
background: rgba(14, 165, 233, 0.35) !important;
|
||||||
|
border: 2px solid #38bdf8 !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-flow__selection {
|
.react-flow__selection {
|
||||||
|
|
@ -7042,76 +7046,16 @@ 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.is-connect-mode .topo-node__handle:hover {
|
.topo-node__handle--center {
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ type HistorySnap = {
|
||||||
nodes: Node<NeNodeData>[];
|
nodes: Node<NeNodeData>[];
|
||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
pendingEdgeDeletes: string[];
|
pendingEdgeDeletes: string[];
|
||||||
|
pendingEdgeCreates: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PaletteSource = "managed" | "ume";
|
type PaletteSource = "managed" | "ume";
|
||||||
|
|
@ -330,6 +331,15 @@ function newId(): string {
|
||||||
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Client-only edge id until Save calls createFabricManualEdge. */
|
||||||
|
function newLocalEdgeId(): string {
|
||||||
|
return `local:${newId()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalPendingEdgeId(id: string): boolean {
|
||||||
|
return String(id || "").startsWith("local:");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icon tone for /topo/ne-router.png (base = blue).
|
* Icon tone for /topo/ne-router.png (base = blue).
|
||||||
* Unmanaged (no managed/ume) and unknown vendors → gray.
|
* Unmanaged (no managed/ume) and unknown vendors → gray.
|
||||||
|
|
@ -431,12 +441,6 @@ 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" : ""}${
|
||||||
|
|
@ -445,16 +449,19 @@ 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 }) => (
|
{/* Single center anchors on the router glyph — same attachment as discovered edges. */}
|
||||||
<Handle
|
<Handle
|
||||||
key={id}
|
type="target"
|
||||||
id={id}
|
position={Position.Left}
|
||||||
type="source"
|
className="topo-node__handle topo-node__handle--center"
|
||||||
position={position}
|
isConnectable={connectMode}
|
||||||
className={`topo-node__handle topo-node__handle--${id}`}
|
/>
|
||||||
isConnectable={connectMode}
|
<Handle
|
||||||
/>
|
type="source"
|
||||||
))}
|
position={Position.Right}
|
||||||
|
className="topo-node__handle topo-node__handle--center"
|
||||||
|
isConnectable={connectMode}
|
||||||
|
/>
|
||||||
<RouterIcon />
|
<RouterIcon />
|
||||||
{showBadge ? (
|
{showBadge ? (
|
||||||
<span className="topo-node__badge" aria-hidden>
|
<span className="topo-node__badge" aria-hidden>
|
||||||
|
|
@ -743,13 +750,11 @@ 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 on the router glyph (56×56, top-centered).
|
// Predetermined handles must match DOM anchors (icon center), not the 160px box edges —
|
||||||
// ConnectionMode.Loose treats these source handles as connectable targets too.
|
// otherwise edges float in the gap and never touch the router glyph.
|
||||||
handles: [
|
handles: [
|
||||||
{ type: "source", position: Position.Top, id: "n", x: TOPO_HANDLE_X, y: 0 },
|
{ type: "target", position: Position.Left, 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.Right, x: TOPO_HANDLE_X, 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,
|
||||||
|
|
@ -779,7 +784,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: "smoothstep",
|
type: "straight",
|
||||||
label: label || undefined,
|
label: label || undefined,
|
||||||
animated: false,
|
animated: false,
|
||||||
data,
|
data,
|
||||||
|
|
@ -944,6 +949,8 @@ export function TopologyPage() {
|
||||||
const historyLockRef = useRef(false);
|
const historyLockRef = useRef(false);
|
||||||
/** Fabric edge ids removed locally; flushed on Save (not when only removing nodes from view). */
|
/** Fabric edge ids removed locally; flushed on Save (not when only removing nodes from view). */
|
||||||
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
|
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
|
||||||
|
/** Local `local:*` edge ids added on canvas; flushed on Save via createFabricManualEdge. */
|
||||||
|
const pendingEdgeCreatesRef = useRef<Set<string>>(new Set());
|
||||||
const connectClickRef = useRef<string | null>(null);
|
const connectClickRef = useRef<string | null>(null);
|
||||||
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
||||||
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
||||||
|
|
@ -1289,6 +1296,7 @@ export function TopologyPage() {
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
redoRef.current = [];
|
redoRef.current = [];
|
||||||
pendingEdgeDeletesRef.current = new Set();
|
pendingEdgeDeletesRef.current = new Set();
|
||||||
|
pendingEdgeCreatesRef.current = new Set();
|
||||||
clearDirty();
|
clearDirty();
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
needsInitialFitRef.current = true;
|
needsInitialFitRef.current = true;
|
||||||
|
|
@ -1330,6 +1338,7 @@ export function TopologyPage() {
|
||||||
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
||||||
edges: edges.map((e) => ({ ...e })),
|
edges: edges.map((e) => ({ ...e })),
|
||||||
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
||||||
|
pendingEdgeCreates: [...pendingEdgeCreatesRef.current],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
redoRef.current = [];
|
redoRef.current = [];
|
||||||
|
|
@ -1343,11 +1352,13 @@ export function TopologyPage() {
|
||||||
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
||||||
edges: edges.map((e) => ({ ...e })),
|
edges: edges.map((e) => ({ ...e })),
|
||||||
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
||||||
|
pendingEdgeCreates: [...pendingEdgeCreatesRef.current],
|
||||||
});
|
});
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
setNodes(prev.nodes);
|
setNodes(prev.nodes);
|
||||||
setEdges(prev.edges);
|
setEdges(prev.edges);
|
||||||
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
|
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
|
||||||
|
pendingEdgeCreatesRef.current = new Set(prev.pendingEdgeCreates || []);
|
||||||
markDirty();
|
markDirty();
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
|
|
@ -1360,11 +1371,13 @@ export function TopologyPage() {
|
||||||
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
||||||
edges: edges.map((e) => ({ ...e })),
|
edges: edges.map((e) => ({ ...e })),
|
||||||
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
||||||
|
pendingEdgeCreates: [...pendingEdgeCreatesRef.current],
|
||||||
});
|
});
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
setNodes(next.nodes);
|
setNodes(next.nodes);
|
||||||
setEdges(next.edges);
|
setEdges(next.edges);
|
||||||
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
|
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
|
||||||
|
pendingEdgeCreatesRef.current = new Set(next.pendingEdgeCreates || []);
|
||||||
markDirty();
|
markDirty();
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
|
|
@ -1388,7 +1401,10 @@ export function TopologyPage() {
|
||||||
if (opts?.persist && mapId) {
|
if (opts?.persist && mapId) {
|
||||||
try {
|
try {
|
||||||
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
|
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
|
||||||
if (pendingEdgeDeletesRef.current.size === 0) {
|
if (
|
||||||
|
pendingEdgeDeletesRef.current.size === 0 &&
|
||||||
|
pendingEdgeCreatesRef.current.size === 0
|
||||||
|
) {
|
||||||
clearDirty();
|
clearDirty();
|
||||||
}
|
}
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
|
|
@ -1538,11 +1554,24 @@ export function TopologyPage() {
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!mapId) throw new Error(t("topology.selectMap"));
|
if (!mapId) throw new Error(t("topology.selectMap"));
|
||||||
const pendingEdges = [...pendingEdgeDeletesRef.current];
|
const createIds = [...pendingEdgeCreatesRef.current];
|
||||||
|
for (const id of createIds) {
|
||||||
|
const e = edges.find((x) => x.id === id);
|
||||||
|
if (!e) continue;
|
||||||
|
const d = (e.data || {}) as EdgeStyleData;
|
||||||
|
await createFabricManualEdge({
|
||||||
|
a_node_id: e.source,
|
||||||
|
b_node_id: e.target,
|
||||||
|
a_port: String(d.source_port || ""),
|
||||||
|
b_port: String(d.target_port || ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pendingEdgeCreatesRef.current.clear();
|
||||||
|
const pendingEdges = [...pendingEdgeDeletesRef.current].filter((id) => !isLocalPendingEdgeId(id));
|
||||||
if (pendingEdges.length) {
|
if (pendingEdges.length) {
|
||||||
await deleteFabricEdges(pendingEdges);
|
await deleteFabricEdges(pendingEdges);
|
||||||
pendingEdgeDeletesRef.current.clear();
|
|
||||||
}
|
}
|
||||||
|
pendingEdgeDeletesRef.current.clear();
|
||||||
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);
|
||||||
|
|
@ -1601,11 +1630,26 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (dirtyRef.current) {
|
if (dirtyRef.current) {
|
||||||
const pendingEdges = [...pendingEdgeDeletesRef.current];
|
const createIds = [...pendingEdgeCreatesRef.current];
|
||||||
|
for (const id of createIds) {
|
||||||
|
const e = edges.find((x) => x.id === id);
|
||||||
|
if (!e) continue;
|
||||||
|
const d = (e.data || {}) as EdgeStyleData;
|
||||||
|
await createFabricManualEdge({
|
||||||
|
a_node_id: e.source,
|
||||||
|
b_node_id: e.target,
|
||||||
|
a_port: String(d.source_port || ""),
|
||||||
|
b_port: String(d.target_port || ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pendingEdgeCreatesRef.current.clear();
|
||||||
|
const pendingEdges = [...pendingEdgeDeletesRef.current].filter(
|
||||||
|
(id) => !isLocalPendingEdgeId(id),
|
||||||
|
);
|
||||||
if (pendingEdges.length) {
|
if (pendingEdges.length) {
|
||||||
await deleteFabricEdges(pendingEdges);
|
await deleteFabricEdges(pendingEdges);
|
||||||
pendingEdgeDeletesRef.current.clear();
|
|
||||||
}
|
}
|
||||||
|
pendingEdgeDeletesRef.current.clear();
|
||||||
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);
|
||||||
|
|
@ -1831,29 +1875,40 @@ export function TopologyPage() {
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => {
|
(connection: Connection) => {
|
||||||
if (!mapId || !isValidConnection(connection)) return;
|
if (!mapId || !isValidConnection(connection)) return;
|
||||||
|
const source = String(connection.source || "");
|
||||||
|
const target = String(connection.target || "");
|
||||||
|
if (!source || !target) return;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
connectClickRef.current = null;
|
connectClickRef.current = null;
|
||||||
void (async () => {
|
const id = newLocalEdgeId();
|
||||||
try {
|
const data: EdgeStyleData = {
|
||||||
await createFabricManualEdge({
|
source: "manual",
|
||||||
a_node_id: String(connection.source || ""),
|
source_port: "",
|
||||||
b_node_id: String(connection.target || ""),
|
target_port: "",
|
||||||
a_port: "",
|
stroke_color: "",
|
||||||
b_port: "",
|
stroke_width: 0,
|
||||||
});
|
line_style: "",
|
||||||
const graph = await fetchTopologyGraph(mapId);
|
discovered_at: null,
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
};
|
||||||
historyLockRef.current = true;
|
const edge = withEdgeVisual(
|
||||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
{
|
||||||
historyLockRef.current = false;
|
id,
|
||||||
pendingEdgeDeletesRef.current = new Set();
|
source,
|
||||||
clearDirty();
|
target,
|
||||||
} catch (err) {
|
// Center anchors only — omit handle ids so attach matches discovered edges after Save.
|
||||||
showError(String(err));
|
sourceHandle: null,
|
||||||
}
|
targetHandle: null,
|
||||||
})();
|
type: "straight",
|
||||||
|
animated: false,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
edgeDefaults,
|
||||||
|
);
|
||||||
|
pendingEdgeCreatesRef.current.add(id);
|
||||||
|
setEdges((es) => [...es, edge]);
|
||||||
|
markDirty();
|
||||||
},
|
},
|
||||||
[setEdges, setNodes, pushHistory, edgeDefaults, isValidConnection, mapId, queryClient, clearDirty, showError],
|
[mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addPaletteItems = useCallback(
|
const addPaletteItems = useCallback(
|
||||||
|
|
@ -2110,7 +2165,13 @@ export function TopologyPage() {
|
||||||
if (!window.confirm(confirmMsg)) return false;
|
if (!window.confirm(confirmMsg)) return false;
|
||||||
}
|
}
|
||||||
pushHistory();
|
pushHistory();
|
||||||
for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id);
|
for (const id of edgeIds) {
|
||||||
|
if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) {
|
||||||
|
pendingEdgeCreatesRef.current.delete(id);
|
||||||
|
} else {
|
||||||
|
pendingEdgeDeletesRef.current.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
const idSet = new Set(edgeIds);
|
const idSet = new Set(edgeIds);
|
||||||
setEdges((es) => es.filter((e) => !idSet.has(e.id)));
|
setEdges((es) => es.filter((e) => !idSet.has(e.id)));
|
||||||
setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur));
|
setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur));
|
||||||
|
|
@ -2143,7 +2204,13 @@ export function TopologyPage() {
|
||||||
if (!window.confirm(msg)) return;
|
if (!window.confirm(msg)) return;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
const nodeSet = new Set(nodeIds);
|
const nodeSet = new Set(nodeIds);
|
||||||
for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id);
|
for (const id of edgeIds) {
|
||||||
|
if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) {
|
||||||
|
pendingEdgeCreatesRef.current.delete(id);
|
||||||
|
} else {
|
||||||
|
pendingEdgeDeletesRef.current.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
|
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
|
||||||
setEdges((es) =>
|
setEdges((es) =>
|
||||||
es.filter(
|
es.filter(
|
||||||
|
|
@ -2170,7 +2237,14 @@ export function TopologyPage() {
|
||||||
const nodeSet = new Set(nodeIds);
|
const nodeSet = new Set(nodeIds);
|
||||||
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
|
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
|
||||||
// Drop incident edges from the canvas only — do not queue Fabric deletes.
|
// Drop incident edges from the canvas only — do not queue Fabric deletes.
|
||||||
setEdges((es) => es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target)));
|
setEdges((es) => {
|
||||||
|
const next = es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target));
|
||||||
|
for (const e of es) {
|
||||||
|
if (next.some((x) => x.id === e.id)) continue;
|
||||||
|
pendingEdgeCreatesRef.current.delete(e.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
markDirty();
|
markDirty();
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -2322,7 +2396,14 @@ export function TopologyPage() {
|
||||||
pushHistory();
|
pushHistory();
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
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) => {
|
||||||
|
const next = es.filter((e) => e.source !== nodeId && e.target !== nodeId);
|
||||||
|
for (const e of es) {
|
||||||
|
if (next.some((x) => x.id === e.id)) continue;
|
||||||
|
pendingEdgeCreatesRef.current.delete(e.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
markDirty();
|
markDirty();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2337,11 +2418,26 @@ export function TopologyPage() {
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
try {
|
try {
|
||||||
if (dirtyRef.current) {
|
if (dirtyRef.current) {
|
||||||
const pendingEdges = [...pendingEdgeDeletesRef.current];
|
const createIds = [...pendingEdgeCreatesRef.current];
|
||||||
|
for (const id of createIds) {
|
||||||
|
const e = edges.find((x) => x.id === id);
|
||||||
|
if (!e || e.source === nodeId || e.target === nodeId) continue;
|
||||||
|
const d = (e.data || {}) as EdgeStyleData;
|
||||||
|
await createFabricManualEdge({
|
||||||
|
a_node_id: e.source,
|
||||||
|
b_node_id: e.target,
|
||||||
|
a_port: String(d.source_port || ""),
|
||||||
|
b_port: String(d.target_port || ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pendingEdgeCreatesRef.current.clear();
|
||||||
|
const pendingEdges = [...pendingEdgeDeletesRef.current].filter(
|
||||||
|
(id) => !isLocalPendingEdgeId(id),
|
||||||
|
);
|
||||||
if (pendingEdges.length) {
|
if (pendingEdges.length) {
|
||||||
await deleteFabricEdges(pendingEdges);
|
await deleteFabricEdges(pendingEdges);
|
||||||
pendingEdgeDeletesRef.current.clear();
|
|
||||||
}
|
}
|
||||||
|
pendingEdgeDeletesRef.current.clear();
|
||||||
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);
|
||||||
|
|
@ -2366,6 +2462,7 @@ export function TopologyPage() {
|
||||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
pendingEdgeDeletesRef.current = new Set();
|
pendingEdgeDeletesRef.current = new Set();
|
||||||
|
pendingEdgeCreatesRef.current = new Set();
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
redoRef.current = [];
|
redoRef.current = [];
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
|
|
@ -2378,6 +2475,7 @@ export function TopologyPage() {
|
||||||
[
|
[
|
||||||
mapId,
|
mapId,
|
||||||
nodes,
|
nodes,
|
||||||
|
edges,
|
||||||
graphQuery.data,
|
graphQuery.data,
|
||||||
closeCtxMenu,
|
closeCtxMenu,
|
||||||
queryClient,
|
queryClient,
|
||||||
|
|
@ -2787,8 +2885,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);
|
||||||
// Connect mode uses Visio-style drag from handles (onConnect), not click-click.
|
// Connect mode: drag from center handle (onConnect). Clicks only select the NE.
|
||||||
if (toolMode === "connect") return;
|
if (toolMode === "connect") {
|
||||||
|
focusNode(node.id, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey);
|
focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey);
|
||||||
},
|
},
|
||||||
[toolMode, focusNode],
|
[toolMode, focusNode],
|
||||||
|
|
@ -4137,9 +4238,9 @@ export function TopologyPage() {
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
onlyRenderVisibleElements
|
onlyRenderVisibleElements
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
connectionLineType={ConnectionLineType.SmoothStep}
|
connectionLineType={ConnectionLineType.Straight}
|
||||||
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }}
|
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }}
|
||||||
defaultEdgeOptions={{ type: "smoothstep", labelShowBg: false }}
|
defaultEdgeOptions={{ type: "straight", labelShowBg: false }}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
minZoom={0.05}
|
minZoom={0.05}
|
||||||
maxZoom={4}
|
maxZoom={4}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from "@xyflow/react";
|
import { BaseEdge, EdgeLabelRenderer, getStraightPath, type EdgeProps } from "@xyflow/react";
|
||||||
|
|
||||||
/** Offset parallel physical links so they do not stack on one line (Visio-like elbows). */
|
/** Offset parallel physical links so they do not stack on one line. */
|
||||||
export function ParallelEdge({
|
export function ParallelEdge({
|
||||||
id,
|
id,
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
style,
|
style,
|
||||||
markerEnd,
|
markerEnd,
|
||||||
label,
|
label,
|
||||||
|
|
@ -18,20 +16,17 @@ 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) * 12;
|
const offset = (index - (count - 1) / 2) * 10;
|
||||||
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] = getSmoothStepPath({
|
const [path, labelX, labelY] = getStraightPath({
|
||||||
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 || "smoothstep",
|
type: count > 1 ? "topoParallel" : e.type || "straight",
|
||||||
// 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: "smoothstep",
|
type: "straight",
|
||||||
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: "smoothstep",
|
type: "straight",
|
||||||
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 },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue