mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +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,
|
||||
ViewPopulateRequest,
|
||||
ViewPositionsPatch,
|
||||
ViewProjectNeighborsRequest,
|
||||
)
|
||||
from .topology_discover import get_discover_job, start_discover_job
|
||||
from .topology_fabric import (
|
||||
|
|
@ -319,8 +320,14 @@ def api_create_topology_placeholder(
|
|||
|
||||
|
||||
@router.post("/views/{view_id}/project-neighbors")
|
||||
def api_project_neighbors(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||
return project_fabric_neighbors_to_view(db, view_id).model_dump()
|
||||
def api_project_neighbors(
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -238,6 +238,18 @@ class ViewPopulateRequest(BaseModel):
|
|||
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):
|
||||
fabric_node_id: str = Field(min_length=1, max_length=64)
|
||||
x: float = 0.0
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ from .topology_schemas import (
|
|||
ViewPopulateOut,
|
||||
ViewPopulateRequest,
|
||||
ViewPositionsPatch,
|
||||
ViewProjectNeighborsRequest,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -288,18 +289,31 @@ def _place_fabric_ids_on_view(
|
|||
fabric_ids: list[str],
|
||||
*,
|
||||
existing: set[str],
|
||||
near_fabric_ids: set[str] | None = None,
|
||||
) -> int:
|
||||
now = _utcnow()
|
||||
added = 0
|
||||
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)
|
||||
base_x = max_x + 200.0
|
||||
anchor = None
|
||||
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)
|
||||
for i, fid in enumerate(fabric_ids):
|
||||
if fid in existing or db.get(TopoFabricNode, fid) is None:
|
||||
continue
|
||||
x = base_x + (i % cols) * 180.0
|
||||
y = 40.0 + (i // cols) * 120.0
|
||||
y = base_y + (i // cols) * 120.0
|
||||
db.add(
|
||||
TopoViewNode(
|
||||
id=uuid4().hex,
|
||||
|
|
@ -897,8 +911,16 @@ def _neighbor_ids(
|
|||
return found
|
||||
|
||||
|
||||
def project_fabric_neighbors_to_view(db: Session, view_id: str) -> TopologyViewGraphOut:
|
||||
"""Add in-scope fabric neighbors onto the leaf view (bounded by membership)."""
|
||||
def project_fabric_neighbors_to_view(
|
||||
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)
|
||||
view = _get_view_or_404(db, view_id)
|
||||
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)
|
||||
filt = dict(view.filter or {})
|
||||
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()
|
||||
# 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"
|
||||
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] = []
|
||||
for peer in sorted(peer_ids):
|
||||
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)
|
||||
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()
|
||||
g = get_view_graph(db, view.id)
|
||||
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()
|
||||
if not view_id:
|
||||
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]:
|
||||
|
|
@ -515,11 +522,25 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"name": "projectTopologyNeighbors",
|
||||
"description": (
|
||||
"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": {
|
||||
"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"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from netx_api.topology_schemas import (
|
|||
ViewNodesAdd,
|
||||
ViewPopulateRequest,
|
||||
ViewPositionsPatch,
|
||||
ViewProjectNeighborsRequest,
|
||||
ViewNodeIn,
|
||||
)
|
||||
|
||||
|
|
@ -679,6 +680,31 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
self.assertLessEqual(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(
|
||||
self.db,
|
||||
view.id,
|
||||
|
|
|
|||
|
|
@ -1486,7 +1486,7 @@ const en = {
|
|||
snapGrid: "Snap to grid",
|
||||
autoLayoutDiscover: "Auto-layout after 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",
|
||||
redo: "Redo",
|
||||
discoverSelected: "Discover selected ({{count}})",
|
||||
|
|
|
|||
|
|
@ -1477,7 +1477,7 @@ const zh = {
|
|||
snapGrid: "网格吸附",
|
||||
autoLayoutDiscover: "发现后自动布局",
|
||||
discoverAutoAddUnmatched: "发现时自动入库未匹配网元(占位)",
|
||||
discoverProjectNeighbors: "发现后将直连邻居加入画布",
|
||||
discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布",
|
||||
undo: "撤销",
|
||||
redo: "重做",
|
||||
discoverSelected: "发现选中 ({{count}})",
|
||||
|
|
|
|||
|
|
@ -1620,13 +1620,16 @@ export function TopologyPage() {
|
|||
throw new Error(job.error || "discover_failed");
|
||||
}
|
||||
const projected = discoverProjectNeighbors
|
||||
? await projectTopologyNeighbors(mapId)
|
||||
? await projectTopologyNeighbors(mapId, {
|
||||
seed_fabric_node_ids: scoped.map((n) => n.id),
|
||||
})
|
||||
: await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
|
||||
appliedMapIdRef.current = mapId;
|
||||
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
|
||||
// Keep existing node positions when we did not auto-layout.
|
||||
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
||||
const beforeIds = new Set(nodes.map((n) => n.id));
|
||||
rfNodes = rfNodes.map((n) => {
|
||||
const p = localPos.get(n.id);
|
||||
return p ? { ...n, position: { ...p } } : n;
|
||||
|
|
@ -1649,7 +1652,8 @@ export function TopologyPage() {
|
|||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
historyLockRef.current = false;
|
||||
if (didAutoLayout) {
|
||||
const addedNodes = rfNodes.filter((n) => !beforeIds.has(n.id)).length;
|
||||
if (didAutoLayout || addedNodes > 0) {
|
||||
needsInitialFitRef.current = true;
|
||||
scheduleFitView(FIT_VIEW_OPTS);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1084,8 +1084,14 @@ export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[])
|
|||
fabric_node_ids: fabricNodeIds,
|
||||
});
|
||||
|
||||
export const projectTopologyNeighbors = (viewId: string) =>
|
||||
apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, {});
|
||||
export const projectTopologyNeighbors = (
|
||||
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 = (
|
||||
viewId: string,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue