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:
oliver 2026-08-09 23:23:09 +08:00
parent a9a45d39d4
commit 75d03f5f9b
8 changed files with 342 additions and 157 deletions

View file

@ -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,
}

View file

@ -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)

View file

@ -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"

View file

@ -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: