mirror of
https://github.com/hansjone/netx.git
synced 2026-10-19 03:00:46 +08:00
Project discover neighbors from scanned seeds only.
Accept optional seed ids on project-neighbors; canvas discover passes scanned fabric nodes and places new peers near them. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7cd89aaae9
commit
03ef9eb39a
9 changed files with 137 additions and 18 deletions
|
|
@ -43,6 +43,7 @@ from .topology_schemas import (
|
||||||
ViewNodesRemove,
|
ViewNodesRemove,
|
||||||
ViewPopulateRequest,
|
ViewPopulateRequest,
|
||||||
ViewPositionsPatch,
|
ViewPositionsPatch,
|
||||||
|
ViewProjectNeighborsRequest,
|
||||||
)
|
)
|
||||||
from .topology_discover import get_discover_job, start_discover_job
|
from .topology_discover import get_discover_job, start_discover_job
|
||||||
from .topology_fabric import (
|
from .topology_fabric import (
|
||||||
|
|
@ -319,8 +320,14 @@ def api_create_topology_placeholder(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/views/{view_id}/project-neighbors")
|
@router.post("/views/{view_id}/project-neighbors")
|
||||||
def api_project_neighbors(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def api_project_neighbors(
|
||||||
return project_fabric_neighbors_to_view(db, view_id).model_dump()
|
view_id: str,
|
||||||
|
body: ViewProjectNeighborsRequest | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return project_fabric_neighbors_to_view(
|
||||||
|
db, view_id, body or ViewProjectNeighborsRequest()
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/views/{view_id}/populate")
|
@router.post("/views/{view_id}/populate")
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,18 @@ class ViewPopulateRequest(BaseModel):
|
||||||
freeze_after: bool = True
|
freeze_after: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ViewProjectNeighborsRequest(BaseModel):
|
||||||
|
"""Project fabric neighbors onto a view.
|
||||||
|
|
||||||
|
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
|
||||||
|
are already placed on the view).
|
||||||
|
"""
|
||||||
|
|
||||||
|
seed_fabric_node_ids: list[str] = Field(default_factory=list)
|
||||||
|
managed_ne_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ViewNodeIn(BaseModel):
|
class ViewNodeIn(BaseModel):
|
||||||
fabric_node_id: str = Field(min_length=1, max_length=64)
|
fabric_node_id: str = Field(min_length=1, max_length=64)
|
||||||
x: float = 0.0
|
x: float = 0.0
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ from .topology_schemas import (
|
||||||
ViewPopulateOut,
|
ViewPopulateOut,
|
||||||
ViewPopulateRequest,
|
ViewPopulateRequest,
|
||||||
ViewPositionsPatch,
|
ViewPositionsPatch,
|
||||||
|
ViewProjectNeighborsRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -288,18 +289,31 @@ def _place_fabric_ids_on_view(
|
||||||
fabric_ids: list[str],
|
fabric_ids: list[str],
|
||||||
*,
|
*,
|
||||||
existing: set[str],
|
existing: set[str],
|
||||||
|
near_fabric_ids: set[str] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
added = 0
|
added = 0
|
||||||
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||||
max_x = max((float(vn.x or 0) for vn in vnodes), default=40.0)
|
anchor = None
|
||||||
base_x = max_x + 200.0
|
near = {str(x).strip() for x in (near_fabric_ids or set()) if str(x).strip()}
|
||||||
|
if near:
|
||||||
|
for vn in vnodes:
|
||||||
|
if vn.fabric_node_id in near:
|
||||||
|
anchor = vn
|
||||||
|
break
|
||||||
|
if anchor is not None:
|
||||||
|
base_x = float(anchor.x or 0.0) + 200.0
|
||||||
|
base_y = float(anchor.y or 0.0)
|
||||||
|
else:
|
||||||
|
max_x = max((float(vn.x or 0) for vn in vnodes), default=40.0)
|
||||||
|
base_x = max_x + 200.0
|
||||||
|
base_y = 40.0
|
||||||
cols = max(1, int(len(fabric_ids) ** 0.5) or 1)
|
cols = max(1, int(len(fabric_ids) ** 0.5) or 1)
|
||||||
for i, fid in enumerate(fabric_ids):
|
for i, fid in enumerate(fabric_ids):
|
||||||
if fid in existing or db.get(TopoFabricNode, fid) is None:
|
if fid in existing or db.get(TopoFabricNode, fid) is None:
|
||||||
continue
|
continue
|
||||||
x = base_x + (i % cols) * 180.0
|
x = base_x + (i % cols) * 180.0
|
||||||
y = 40.0 + (i // cols) * 120.0
|
y = base_y + (i // cols) * 120.0
|
||||||
db.add(
|
db.add(
|
||||||
TopoViewNode(
|
TopoViewNode(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
|
|
@ -897,8 +911,16 @@ def _neighbor_ids(
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewGraphOut:
|
def project_fabric_neighbors_to_view(
|
||||||
"""Add in-scope fabric neighbors onto the leaf view (bounded by membership)."""
|
db: Session,
|
||||||
|
view_id: str,
|
||||||
|
body: ViewProjectNeighborsRequest | None = None,
|
||||||
|
) -> TopologyViewGraphOut:
|
||||||
|
"""Add in-scope fabric neighbors onto the leaf view (bounded by membership).
|
||||||
|
|
||||||
|
Optional seeds limit expansion to neighbors of those fabric nodes (must
|
||||||
|
already be on the view). Empty seeds → expand from every canvas node.
|
||||||
|
"""
|
||||||
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)
|
||||||
|
|
@ -909,6 +931,7 @@ def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewG
|
||||||
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()
|
||||||
# Drop placements pointing at missing fabric rows only (keep LLDP placeholders).
|
# Drop placements pointing at missing fabric rows only (keep LLDP placeholders).
|
||||||
|
|
@ -929,7 +952,25 @@ def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewG
|
||||||
g.truncate_reason = g.truncate_reason or "membership_cap"
|
g.truncate_reason = g.truncate_reason or "membership_cap"
|
||||||
return g
|
return g
|
||||||
|
|
||||||
peer_ids = _neighbor_ids(db, seed_ids=existing, layer=layer, hops=hops)
|
seed_ids: set[str] = {
|
||||||
|
str(x).strip() for x in (req.seed_fabric_node_ids or []) if str(x).strip()
|
||||||
|
}
|
||||||
|
for mid in req.managed_ne_ids or []:
|
||||||
|
mid_s = str(mid or "").strip()
|
||||||
|
if not mid_s:
|
||||||
|
continue
|
||||||
|
for fid in existing:
|
||||||
|
fn = db.get(TopoFabricNode, fid)
|
||||||
|
if fn is not None and str(fn.managed_ne_id or "").strip() == mid_s:
|
||||||
|
seed_ids.add(fid)
|
||||||
|
if seed_ids:
|
||||||
|
seed_ids &= existing
|
||||||
|
if not seed_ids:
|
||||||
|
return get_view_graph(db, view.id)
|
||||||
|
else:
|
||||||
|
seed_ids = set(existing)
|
||||||
|
|
||||||
|
peer_ids = _neighbor_ids(db, seed_ids=seed_ids, layer=layer, hops=hops)
|
||||||
to_add: list[str] = []
|
to_add: list[str] = []
|
||||||
for peer in sorted(peer_ids):
|
for peer in sorted(peer_ids):
|
||||||
if peer in existing:
|
if peer in existing:
|
||||||
|
|
@ -947,7 +988,9 @@ def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewG
|
||||||
|
|
||||||
truncated = len(peer_ids) > len(to_add)
|
truncated = len(peer_ids) > len(to_add)
|
||||||
if to_add:
|
if to_add:
|
||||||
_place_fabric_ids_on_view(db, view, to_add, existing=existing)
|
_place_fabric_ids_on_view(
|
||||||
|
db, view, to_add, existing=existing, near_fabric_ids=seed_ids
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
g = get_view_graph(db, view.id)
|
g = get_view_graph(db, view.id)
|
||||||
if truncated:
|
if truncated:
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,14 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
view_id = str(args.get("view_id") or "").strip()
|
view_id = str(args.get("view_id") or "").strip()
|
||||||
if not view_id:
|
if not view_id:
|
||||||
return {"ok": False, "error": "view_id_required"}
|
return {"ok": False, "error": "view_id_required"}
|
||||||
return _data(http_json("POST", f"/v1/topology/views/{view_id}/project-neighbors", body={}))
|
body: dict[str, Any] = {}
|
||||||
|
seeds = args.get("seed_fabric_node_ids") or args.get("fabric_node_ids") or []
|
||||||
|
if isinstance(seeds, list) and seeds:
|
||||||
|
body["seed_fabric_node_ids"] = [str(x).strip() for x in seeds if str(x).strip()]
|
||||||
|
mids = args.get("managed_ne_ids") or []
|
||||||
|
if isinstance(mids, list) and mids:
|
||||||
|
body["managed_ne_ids"] = [str(x).strip() for x in mids if str(x).strip()]
|
||||||
|
return _data(http_json("POST", f"/v1/topology/views/{view_id}/project-neighbors", body=body))
|
||||||
|
|
||||||
|
|
||||||
def _get_topology_fabric_summary(_args: dict[str, Any]) -> dict[str, Any]:
|
def _get_topology_fabric_summary(_args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
@ -515,11 +522,25 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
||||||
"name": "projectTopologyNeighbors",
|
"name": "projectTopologyNeighbors",
|
||||||
"description": (
|
"description": (
|
||||||
"Project existing LLDP fabric neighbors of nodes already on the view onto the canvas. "
|
"Project existing LLDP fabric neighbors of nodes already on the view onto the canvas. "
|
||||||
"Only places nodes that already exist in fabric."
|
"Only places nodes that already exist in fabric. "
|
||||||
|
"Optional seed_fabric_node_ids / managed_ne_ids limit expansion to those seeds; "
|
||||||
|
"omit to expand from every node on the view."
|
||||||
),
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"view_id": {"type": "string"}},
|
"properties": {
|
||||||
|
"view_id": {"type": "string"},
|
||||||
|
"seed_fabric_node_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional fabric node ids already on the view to expand from",
|
||||||
|
},
|
||||||
|
"managed_ne_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional managed NE ids to resolve to on-view fabric seeds",
|
||||||
|
},
|
||||||
|
},
|
||||||
"required": ["view_id"],
|
"required": ["view_id"],
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from netx_api.topology_schemas import (
|
||||||
ViewNodesAdd,
|
ViewNodesAdd,
|
||||||
ViewPopulateRequest,
|
ViewPopulateRequest,
|
||||||
ViewPositionsPatch,
|
ViewPositionsPatch,
|
||||||
|
ViewProjectNeighborsRequest,
|
||||||
ViewNodeIn,
|
ViewNodeIn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -679,6 +680,31 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
self.assertLessEqual(len(g.nodes), 2)
|
self.assertLessEqual(len(g.nodes), 2)
|
||||||
self.assertTrue(g.truncated or len(g.nodes) == 2)
|
self.assertTrue(g.truncated or len(g.nodes) == 2)
|
||||||
|
|
||||||
|
# Seed-scoped project: expand only from node 0 → only node 1 among line peers.
|
||||||
|
view2 = svc.create_view(
|
||||||
|
self.db,
|
||||||
|
TopologyViewCreate(
|
||||||
|
name=f"CapSeed-{suffix}",
|
||||||
|
folder_id=self._region(f"CapSeedR-{suffix}"),
|
||||||
|
role="core",
|
||||||
|
filter={"membership": {"expand_hops": 1, "max_nodes": 50, "frozen": False}},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Place endpoints 0 and 2 (not adjacent); seed from 0 should add 1, not 3.
|
||||||
|
svc.add_nodes_to_view(
|
||||||
|
self.db, view2.id, ViewNodesAdd(managed_ne_ids=[nes[0].id, nes[2].id])
|
||||||
|
)
|
||||||
|
g2 = svc.project_fabric_neighbors_to_view(
|
||||||
|
self.db,
|
||||||
|
view2.id,
|
||||||
|
ViewProjectNeighborsRequest(seed_fabric_node_ids=[nodes[0].id]),
|
||||||
|
)
|
||||||
|
ids2 = {n.fabric_node_id for n in g2.nodes}
|
||||||
|
self.assertIn(nodes[0].id, ids2)
|
||||||
|
self.assertIn(nodes[1].id, ids2)
|
||||||
|
self.assertIn(nodes[2].id, ids2)
|
||||||
|
self.assertNotIn(nodes[3].id, ids2)
|
||||||
|
|
||||||
pop = svc.populate_view(
|
pop = svc.populate_view(
|
||||||
self.db,
|
self.db,
|
||||||
view.id,
|
view.id,
|
||||||
|
|
|
||||||
|
|
@ -1486,7 +1486,7 @@ 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: "Project direct neighbors onto canvas after discover",
|
discoverProjectNeighbors: "After discover, project neighbors of scanned NEs onto the canvas",
|
||||||
undo: "Undo",
|
undo: "Undo",
|
||||||
redo: "Redo",
|
redo: "Redo",
|
||||||
discoverSelected: "Discover selected ({{count}})",
|
discoverSelected: "Discover selected ({{count}})",
|
||||||
|
|
|
||||||
|
|
@ -1477,7 +1477,7 @@ const zh = {
|
||||||
snapGrid: "网格吸附",
|
snapGrid: "网格吸附",
|
||||||
autoLayoutDiscover: "发现后自动布局",
|
autoLayoutDiscover: "发现后自动布局",
|
||||||
discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)",
|
discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)",
|
||||||
discoverProjectNeighbors: "发现后将直连邻居加入画布",
|
discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布",
|
||||||
undo: "撤销",
|
undo: "撤销",
|
||||||
redo: "重做",
|
redo: "重做",
|
||||||
discoverSelected: "发现选中 ({{count}})",
|
discoverSelected: "发现选中 ({{count}})",
|
||||||
|
|
|
||||||
|
|
@ -1620,13 +1620,16 @@ export function TopologyPage() {
|
||||||
throw new Error(job.error || "discover_failed");
|
throw new Error(job.error || "discover_failed");
|
||||||
}
|
}
|
||||||
const projected = discoverProjectNeighbors
|
const projected = discoverProjectNeighbors
|
||||||
? await projectTopologyNeighbors(mapId)
|
? await projectTopologyNeighbors(mapId, {
|
||||||
|
seed_fabric_node_ids: scoped.map((n) => n.id),
|
||||||
|
})
|
||||||
: await fetchTopologyGraph(mapId);
|
: await fetchTopologyGraph(mapId);
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
|
||||||
appliedMapIdRef.current = mapId;
|
appliedMapIdRef.current = mapId;
|
||||||
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
|
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
|
||||||
// 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));
|
||||||
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;
|
||||||
|
|
@ -1649,7 +1652,8 @@ export function TopologyPage() {
|
||||||
setNodes(rfNodes);
|
setNodes(rfNodes);
|
||||||
setEdges(rfEdges);
|
setEdges(rfEdges);
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
if (didAutoLayout) {
|
const addedNodes = rfNodes.filter((n) => !beforeIds.has(n.id)).length;
|
||||||
|
if (didAutoLayout || addedNodes > 0) {
|
||||||
needsInitialFitRef.current = true;
|
needsInitialFitRef.current = true;
|
||||||
scheduleFitView(FIT_VIEW_OPTS);
|
scheduleFitView(FIT_VIEW_OPTS);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1084,8 +1084,14 @@ export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[])
|
||||||
fabric_node_ids: fabricNodeIds,
|
fabric_node_ids: fabricNodeIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const projectTopologyNeighbors = (viewId: string) =>
|
export const projectTopologyNeighbors = (
|
||||||
apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, {});
|
viewId: string,
|
||||||
|
body?: { seed_fabric_node_ids?: string[]; managed_ne_ids?: string[] },
|
||||||
|
) =>
|
||||||
|
apiPost<TopologyViewGraph>(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,
|
||||||
|
body || {},
|
||||||
|
);
|
||||||
|
|
||||||
export const patchTopologyEdgeStyle = (
|
export const patchTopologyEdgeStyle = (
|
||||||
viewId: string,
|
viewId: string,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue