mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Raise physical canvas capacity and keep region==canvas rules consistent.
Pass view kind into membership so metro maps are not capped at core=80, add region-scoped neighbor projection, and align UI/tests with one physical canvas per region. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a9a45d39d4
commit
75d03f5f9b
8 changed files with 342 additions and 157 deletions
|
|
@ -19,7 +19,8 @@ ROLE_DEFAULT_MAX_NODES = {
|
|||
VIEW_ROLE_ACCESS: 300,
|
||||
}
|
||||
KIND_DEFAULT_MAX_NODES = {
|
||||
VIEW_KIND_PHYSICAL: 500,
|
||||
# Physical 根图 / region canvases: allow full metro on one map (hard cap 2000).
|
||||
VIEW_KIND_PHYSICAL: 2000,
|
||||
VIEW_KIND_CUSTOM: 300,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -253,12 +253,16 @@ class ViewProjectNeighborsRequest(BaseModel):
|
|||
When provided, expand only from those seeds (resolved to fabric nodes that
|
||||
are already placed on the view).
|
||||
|
||||
region_folder_id: when set, only add peers whose fabric.region_folder_id
|
||||
matches (exact). Skipped peers are counted on the graph response.
|
||||
|
||||
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)
|
||||
managed_ne_ids: list[str] = Field(default_factory=list)
|
||||
region_folder_id: str | None = None
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
|
|
@ -338,6 +342,9 @@ class TopologyViewGraphOut(BaseModel):
|
|||
truncated: bool = False
|
||||
truncate_reason: str = ""
|
||||
outside_peers: list[dict[str, str]] = Field(default_factory=list)
|
||||
# Set by project-neighbors when region_folder_id filters peers out.
|
||||
out_of_region_skipped: int = 0
|
||||
out_of_region_sample: list[dict[str, str]] = Field(default_factory=list)
|
||||
world_transform: WorldTransformOut | None = None
|
||||
# Overview starfield: all (or capped) display coords; RF nodes stay empty far out.
|
||||
scatter: list[WorldScatterPointOut] = Field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -297,7 +297,14 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
|||
|
||||
|
||||
def _membership_for_view(view: TopoView) -> dict[str, Any]:
|
||||
return parse_membership(dict(view.filter or {}), role=normalize_view_role(view.role))
|
||||
# Physical canvases use KIND_DEFAULT_MAX_NODES (2000); role defaults (core=80)
|
||||
# only apply to custom views. Omitting kind made every empty-filter physical
|
||||
# canvas silently inherit core=80 and truncate MCP/UI adds early.
|
||||
return parse_membership(
|
||||
dict(view.filter or {}),
|
||||
role=normalize_view_role(view.role),
|
||||
kind=normalize_view_kind(view.kind),
|
||||
)
|
||||
|
||||
|
||||
def _fabric_in_hard_scope(db: Session, fn: TopoFabricNode, mem: dict[str, Any]) -> bool:
|
||||
|
|
@ -1104,7 +1111,10 @@ def project_fabric_neighbors_to_view(
|
|||
if peer_ids
|
||||
else {}
|
||||
)
|
||||
region_v = str(req.region_folder_id or "").strip()
|
||||
eligible: list[str] = []
|
||||
out_of_region_skipped = 0
|
||||
out_of_region_sample: list[dict[str, str]] = []
|
||||
for peer in sorted(peer_ids):
|
||||
if peer in existing:
|
||||
continue
|
||||
|
|
@ -1115,12 +1125,29 @@ def project_fabric_neighbors_to_view(
|
|||
continue
|
||||
if not _fabric_in_hard_scope(db, fn, mem):
|
||||
continue
|
||||
if region_v and str(fn.region_folder_id or "").strip() != region_v:
|
||||
out_of_region_skipped += 1
|
||||
if len(out_of_region_sample) < 20:
|
||||
out_of_region_sample.append(
|
||||
{
|
||||
"fabric_node_id": peer,
|
||||
"name": str(fn.name or ""),
|
||||
"region_folder_id": str(fn.region_folder_id or ""),
|
||||
}
|
||||
)
|
||||
continue
|
||||
eligible.append(peer)
|
||||
|
||||
room = max(0, max_nodes - len(existing))
|
||||
to_add = eligible[:room]
|
||||
truncated = len(eligible) > len(to_add)
|
||||
|
||||
def _attach_region_meta(g: TopologyViewGraphOut) -> TopologyViewGraphOut:
|
||||
if region_v:
|
||||
g.out_of_region_skipped = out_of_region_skipped
|
||||
g.out_of_region_sample = out_of_region_sample
|
||||
return g
|
||||
|
||||
if dry_run:
|
||||
if to_add:
|
||||
nested = db.begin_nested()
|
||||
|
|
@ -1137,7 +1164,7 @@ def project_fabric_neighbors_to_view(
|
|||
if truncated:
|
||||
g.truncated = True
|
||||
g.truncate_reason = g.truncate_reason or "membership_cap"
|
||||
return g
|
||||
return _attach_region_meta(g)
|
||||
|
||||
if to_add:
|
||||
_place_fabric_ids_on_view(
|
||||
|
|
@ -1148,22 +1175,26 @@ def project_fabric_neighbors_to_view(
|
|||
if truncated:
|
||||
g.truncated = True
|
||||
g.truncate_reason = g.truncate_reason or "membership_cap"
|
||||
return g
|
||||
return _attach_region_meta(g)
|
||||
|
||||
|
||||
def populate_view(db: Session, view_id: str, body: ViewPopulateRequest) -> ViewPopulateOut:
|
||||
"""Resolve membership candidates and optionally place them on the leaf view."""
|
||||
view = _get_view_or_404(db, view_id)
|
||||
role = normalize_view_role(view.role)
|
||||
kind = normalize_view_kind(view.kind)
|
||||
if body.membership is not None:
|
||||
filt = merge_filter_with_membership(
|
||||
dict(view.filter or {}), role=role, membership=parse_membership(
|
||||
{"membership": body.membership}, role=role
|
||||
)
|
||||
dict(view.filter or {}),
|
||||
role=role,
|
||||
kind=kind,
|
||||
membership=parse_membership(
|
||||
{"membership": body.membership}, role=role, kind=kind
|
||||
),
|
||||
)
|
||||
if not body.dry_run:
|
||||
view.filter = filt
|
||||
mem = parse_membership(dict(view.filter or {}), role=role)
|
||||
mem = parse_membership(dict(view.filter or {}), role=role, kind=kind)
|
||||
max_nodes = int(mem.get("max_nodes") or 300)
|
||||
hops = int(mem.get("expand_hops") or 1)
|
||||
layer = str((view.filter or {}).get("layer") or "physical").strip() or "physical"
|
||||
|
|
|
|||
|
|
@ -856,14 +856,19 @@ def create_view(db: Session, body: TopologyViewCreate) -> TopologyViewOut:
|
|||
folder = _get_folder_or_404(db, folder_id)
|
||||
if str(folder.kind or "") == "root":
|
||||
raise HTTPException(status_code=400, detail="view_must_hang_under_region")
|
||||
if kind == VIEW_KIND_PHYSICAL:
|
||||
existing = (
|
||||
db.query(TopoView)
|
||||
.filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL)
|
||||
.first()
|
||||
existing_physical = (
|
||||
db.query(TopoView)
|
||||
.filter(TopoView.folder_id == folder.id, TopoView.kind == VIEW_KIND_PHYSICAL)
|
||||
.first()
|
||||
)
|
||||
if kind == VIEW_KIND_PHYSICAL and existing_physical is not None:
|
||||
raise HTTPException(status_code=400, detail="region_already_has_physical_view")
|
||||
# One canvas per region/根图: no sibling custom maps — nest sub-regions instead.
|
||||
if kind == VIEW_KIND_CUSTOM and existing_physical is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="use_create_subregion_folder",
|
||||
)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=400, detail="region_already_has_physical_view")
|
||||
filt = merge_filter_with_membership(dict(body.filter or {}), role=role, kind=kind)
|
||||
now = _utcnow()
|
||||
row = TopoView(
|
||||
|
|
@ -923,6 +928,18 @@ def update_view(db: Session, view_id: str, body: TopologyViewUpdate) -> Topology
|
|||
)
|
||||
if clash is not None:
|
||||
raise HTTPException(status_code=400, detail="region_already_has_physical_view")
|
||||
if new_kind == VIEW_KIND_CUSTOM and normalize_view_kind(row.kind) != VIEW_KIND_CUSTOM:
|
||||
# Do not demote/add custom beside an existing physical canvas.
|
||||
phys = (
|
||||
db.query(TopoView)
|
||||
.filter(
|
||||
TopoView.folder_id == row.folder_id,
|
||||
TopoView.kind == VIEW_KIND_PHYSICAL,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if phys is not None:
|
||||
raise HTTPException(status_code=400, detail="use_create_subregion_folder")
|
||||
row.kind = new_kind
|
||||
row.parent_view_id = None
|
||||
if body.filter is not None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue