mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Add filter-based topology bulk writes and refine API key scopes UX.
Filter add/layout/remove keeps MCP payloads small; tokens default MCP scopes and edit them in a modal with a compact help tip. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bf0dfd66d8
commit
2d92dde8e5
18 changed files with 1294 additions and 257 deletions
|
|
@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||
from .auth_deps import AuthContext, require_admin, require_user
|
||||
from .auth_schemas import (
|
||||
ApiTokenCreateRequest,
|
||||
ApiTokenUpdateRequest,
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
UserCreateRequest,
|
||||
|
|
@ -25,6 +26,7 @@ from .auth_service import (
|
|||
list_users,
|
||||
login_issue_token,
|
||||
revoke_api_token,
|
||||
update_api_token,
|
||||
update_user,
|
||||
user_public,
|
||||
write_audit,
|
||||
|
|
@ -303,6 +305,47 @@ def api_create_token(
|
|||
}
|
||||
|
||||
|
||||
@router.patch("/v1/api-tokens/{token_id}")
|
||||
def api_update_token(
|
||||
token_id: str,
|
||||
body: ApiTokenUpdateRequest,
|
||||
request: Request,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
if body.name is None and body.scopes is None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=400, detail="nothing_to_update")
|
||||
row = update_api_token(
|
||||
db,
|
||||
token_id=token_id,
|
||||
actor=ctx.user,
|
||||
name=body.name,
|
||||
scopes=body.scopes,
|
||||
)
|
||||
ip, ua = _client_meta(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="api_tokens.update",
|
||||
actor_user_id=ctx.user.id,
|
||||
actor_username=ctx.user.username,
|
||||
method="PATCH",
|
||||
path=f"/v1/api-tokens/{token_id}",
|
||||
status_code=200,
|
||||
client_ip=ip,
|
||||
user_agent=ua,
|
||||
detail={
|
||||
"token_id": row.id,
|
||||
"name": row.name,
|
||||
"scopes": getattr(row, "scopes", None) or [],
|
||||
},
|
||||
)
|
||||
from .auth_service import _token_public
|
||||
|
||||
return {"token": _token_public(db, row)}
|
||||
|
||||
|
||||
@router.delete("/v1/api-tokens/{token_id}")
|
||||
def api_revoke_token(
|
||||
token_id: str,
|
||||
|
|
|
|||
|
|
@ -35,5 +35,11 @@ class ApiTokenCreateRequest(BaseModel):
|
|||
expires_in_days: int | None = Field(default=90, ge=0, le=3650)
|
||||
# Admin may create a token for another user; others ignored / forced to self.
|
||||
user_id: str | None = None
|
||||
# Capability subset; empty inherits owner scopes.
|
||||
# Explicit capability list; empty inherits owner scopes (legacy). Prefer non-empty.
|
||||
scopes: list[str] | None = None
|
||||
|
||||
|
||||
class ApiTokenUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=128)
|
||||
# Replace token scopes (capped to owner). Empty list clears to inherit owner scopes.
|
||||
scopes: list[str] | None = None
|
||||
|
|
|
|||
|
|
@ -433,6 +433,47 @@ def revoke_api_token(db: Session, *, token_id: str, actor: AppUser) -> ApiToken:
|
|||
return row
|
||||
|
||||
|
||||
def update_api_token(
|
||||
db: Session,
|
||||
*,
|
||||
token_id: str,
|
||||
actor: AppUser,
|
||||
name: str | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
) -> ApiToken:
|
||||
row = db.query(ApiToken).filter(ApiToken.id == str(token_id)).one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="api_token_not_found")
|
||||
if actor.role != "admin" and row.user_id != actor.id:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
if row.revoked_at is not None:
|
||||
raise HTTPException(status_code=400, detail="api_token_revoked")
|
||||
|
||||
if name is not None:
|
||||
label = str(name or "").strip() or row.name
|
||||
if len(label) > 128:
|
||||
raise HTTPException(status_code=400, detail="token_name_too_long")
|
||||
row.name = label
|
||||
|
||||
if scopes is not None:
|
||||
owner = get_user_by_id(db, row.user_id)
|
||||
if owner is None:
|
||||
raise HTTPException(status_code=404, detail="user_not_found")
|
||||
owner_scopes = effective_user_scopes(
|
||||
role=str(owner.role or "user"), override=getattr(owner, "scopes", None) or []
|
||||
)
|
||||
scope_list = normalize_scopes(scopes)
|
||||
if scope_list:
|
||||
scope_list = sorted(frozenset(scope_list) & owner_scopes)
|
||||
if not scope_list:
|
||||
raise HTTPException(status_code=400, detail="scopes_required")
|
||||
row.scopes = scope_list
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def resolve_api_token_row(db: Session, plaintext: str) -> ApiToken | None:
|
||||
th = hash_api_token(plaintext)
|
||||
row = (
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from .topology_schemas import (
|
|||
TopologyViewUpdate,
|
||||
ViewEdgeStylePatch,
|
||||
ViewNodesAdd,
|
||||
ViewNodesRemove,
|
||||
ViewPopulateRequest,
|
||||
ViewPositionsPatch,
|
||||
)
|
||||
|
|
@ -303,11 +304,10 @@ def api_populate_view(
|
|||
@router.post("/views/{view_id}/nodes/remove")
|
||||
def api_remove_nodes(
|
||||
view_id: str,
|
||||
body: dict[str, Any],
|
||||
body: ViewNodesRemove,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
ids = body.get("fabric_node_ids") if isinstance(body, dict) else None
|
||||
return remove_view_nodes(db, view_id, list(ids or [])).model_dump()
|
||||
return remove_view_nodes(db, view_id, body=body).model_dump()
|
||||
|
||||
|
||||
@router.patch("/views/{view_id}/edge-style")
|
||||
|
|
|
|||
|
|
@ -297,12 +297,48 @@ class ViewPopulateOut(BaseModel):
|
|||
graph: TopologyViewGraphOut | None = None
|
||||
|
||||
|
||||
class ViewMutationOut(BaseModel):
|
||||
"""Summary for bulk view mutations (add / move / remove)."""
|
||||
|
||||
ok: bool = True
|
||||
view_id: str = ""
|
||||
matched: int = 0
|
||||
added: int = 0
|
||||
updated: int = 0
|
||||
removed: int = 0
|
||||
skipped_existing: int = 0
|
||||
skipped_missing: int = 0
|
||||
skipped_locked: int = 0
|
||||
view_node_count: int = 0
|
||||
max_nodes: int = 0
|
||||
truncated: bool = False
|
||||
next_offset: int | None = None
|
||||
graph: TopologyViewGraphOut | None = None
|
||||
|
||||
|
||||
class ViewPositionsPatch(BaseModel):
|
||||
"""Move nodes: explicit positions and/or filter + layout (grid|offset|stack)."""
|
||||
|
||||
positions: list[ViewNodeIn] = Field(default_factory=list)
|
||||
fabric_node_ids: list[str] = Field(default_factory=list)
|
||||
keyword: str = ""
|
||||
role: str = ""
|
||||
vendor: str = ""
|
||||
link_status: str = ""
|
||||
layout: str = Field(default="", description="grid | offset | stack | empty=use positions")
|
||||
origin_x: float = 40.0
|
||||
origin_y: float = 40.0
|
||||
gap_x: float = 180.0
|
||||
gap_y: float = 120.0
|
||||
cols: int = Field(default=0, ge=0, le=2000)
|
||||
dx: float = 0.0
|
||||
dy: float = 0.0
|
||||
# Default True keeps web UI / existing clients returning a full graph.
|
||||
return_graph: bool = True
|
||||
|
||||
|
||||
class ViewNodesAdd(BaseModel):
|
||||
"""Add inventory NEs onto a view (creates fabric nodes as needed)."""
|
||||
"""Add NEs onto a view. Prefer fabric filters for bulk; managed/ume still allowed for UI."""
|
||||
|
||||
managed_ne_ids: list[str] = Field(default_factory=list)
|
||||
ume_ne_ids: list[str] = Field(default_factory=list)
|
||||
|
|
@ -310,8 +346,25 @@ class ViewNodesAdd(BaseModel):
|
|||
default_factory=list,
|
||||
description="Place existing fabric nodes onto the view",
|
||||
)
|
||||
# Optional initial positions keyed by managed/ume id
|
||||
keyword: str = ""
|
||||
role: str = ""
|
||||
vendor: str = ""
|
||||
link_status: str = ""
|
||||
limit: int = Field(default=500, ge=1, le=2000)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
layout: str = Field(default="grid", description="grid | keep")
|
||||
return_graph: bool = True
|
||||
|
||||
|
||||
class ViewNodesRemove(BaseModel):
|
||||
"""Remove placements from a view (does not delete fabric). Filter and/or id list."""
|
||||
|
||||
fabric_node_ids: list[str] = Field(default_factory=list)
|
||||
keyword: str = ""
|
||||
role: str = ""
|
||||
vendor: str = ""
|
||||
link_status: str = ""
|
||||
return_graph: bool = True
|
||||
|
||||
|
||||
class ViewEdgeStylePatch(BaseModel):
|
||||
|
|
|
|||
|
|
@ -61,9 +61,11 @@ from .topology_schemas import (
|
|||
TopologyViewUpdate,
|
||||
ViewEdgeOut,
|
||||
ViewEdgeStylePatch,
|
||||
ViewMutationOut,
|
||||
ViewNodeIn,
|
||||
ViewNodeOut,
|
||||
ViewNodesAdd,
|
||||
ViewNodesRemove,
|
||||
ViewPopulateOut,
|
||||
ViewPopulateRequest,
|
||||
ViewPositionsPatch,
|
||||
|
|
@ -308,57 +310,319 @@ def _place_fabric_ids_on_view(
|
|||
return added
|
||||
|
||||
|
||||
def _has_fabric_filter(
|
||||
*,
|
||||
keyword: str = "",
|
||||
role: str = "",
|
||||
vendor: str = "",
|
||||
link_status: str = "",
|
||||
) -> bool:
|
||||
return bool(
|
||||
str(keyword or "").strip()
|
||||
or str(role or "").strip()
|
||||
or str(vendor or "").strip()
|
||||
or str(link_status or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def _apply_fabric_filters(
|
||||
q: Any,
|
||||
*,
|
||||
keyword: str = "",
|
||||
role: str = "",
|
||||
vendor: str = "",
|
||||
link_status: str = "",
|
||||
) -> Any:
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
TopoFabricNode.name.ilike(like),
|
||||
TopoFabricNode.ip.ilike(like),
|
||||
TopoFabricNode.managed_ne_id.ilike(like),
|
||||
TopoFabricNode.ume_ne_id.ilike(like),
|
||||
)
|
||||
)
|
||||
role_v = str(role or "").strip().lower()
|
||||
if role_v:
|
||||
q = q.filter(TopoFabricNode.role == role_v)
|
||||
vendor_v = str(vendor or "").strip()
|
||||
if vendor_v:
|
||||
q = q.filter(TopoFabricNode.vendor.ilike(f"%{vendor_v}%"))
|
||||
ls = str(link_status or "").strip().lower()
|
||||
if ls == "orphaned":
|
||||
q = q.filter(
|
||||
or_(TopoFabricNode.managed_ne_id.is_(None), TopoFabricNode.managed_ne_id == ""),
|
||||
or_(TopoFabricNode.ume_ne_id.is_(None), TopoFabricNode.ume_ne_id == ""),
|
||||
)
|
||||
elif ls == "linked":
|
||||
q = q.filter(
|
||||
or_(
|
||||
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
|
||||
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
|
||||
)
|
||||
)
|
||||
elif ls == "managed":
|
||||
q = q.filter(
|
||||
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
|
||||
or_(TopoFabricNode.ume_ne_id.is_(None), TopoFabricNode.ume_ne_id == ""),
|
||||
)
|
||||
elif ls == "ume":
|
||||
q = q.filter(
|
||||
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
|
||||
or_(TopoFabricNode.managed_ne_id.is_(None), TopoFabricNode.managed_ne_id == ""),
|
||||
)
|
||||
elif ls == "both":
|
||||
q = q.filter(
|
||||
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
|
||||
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
|
||||
)
|
||||
return q
|
||||
|
||||
|
||||
def select_fabric_ids(
|
||||
db: Session,
|
||||
*,
|
||||
keyword: str = "",
|
||||
role: str = "",
|
||||
vendor: str = "",
|
||||
link_status: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 500,
|
||||
) -> tuple[list[str], int]:
|
||||
"""Server-side fabric id selection for bulk add (paged)."""
|
||||
lim = max(1, min(VIEW_GRAPH_NODE_HARD_CAP, int(limit or 500)))
|
||||
off = max(0, int(offset or 0))
|
||||
q = _apply_fabric_filters(
|
||||
db.query(TopoFabricNode),
|
||||
keyword=keyword,
|
||||
role=role,
|
||||
vendor=vendor,
|
||||
link_status=link_status,
|
||||
)
|
||||
total = int(q.count())
|
||||
rows = q.order_by(TopoFabricNode.name.asc()).offset(off).limit(lim).all()
|
||||
return [str(r.id) for r in rows], total
|
||||
|
||||
|
||||
def select_view_fabric_ids(
|
||||
db: Session,
|
||||
view_id: str,
|
||||
*,
|
||||
fabric_node_ids: list[str] | None = None,
|
||||
keyword: str = "",
|
||||
role: str = "",
|
||||
vendor: str = "",
|
||||
link_status: str = "",
|
||||
) -> list[str]:
|
||||
"""Select fabric ids already placed on a view, optionally narrowed by filter/ids."""
|
||||
explicit = [str(x).strip() for x in (fabric_node_ids or []) if str(x).strip()]
|
||||
q = (
|
||||
db.query(TopoFabricNode.id)
|
||||
.join(TopoViewNode, TopoViewNode.fabric_node_id == TopoFabricNode.id)
|
||||
.filter(TopoViewNode.view_id == view_id)
|
||||
)
|
||||
if explicit:
|
||||
q = q.filter(TopoFabricNode.id.in_(explicit))
|
||||
if _has_fabric_filter(keyword=keyword, role=role, vendor=vendor, link_status=link_status):
|
||||
q = _apply_fabric_filters(
|
||||
q, keyword=keyword, role=role, vendor=vendor, link_status=link_status
|
||||
)
|
||||
rows = q.order_by(TopoFabricNode.name.asc()).all()
|
||||
return [str(r[0] if isinstance(r, tuple) else r.id if hasattr(r, "id") else r) for r in rows]
|
||||
|
||||
|
||||
def _layout_coords(
|
||||
count: int,
|
||||
*,
|
||||
layout: str,
|
||||
origin_x: float,
|
||||
origin_y: float,
|
||||
gap_x: float,
|
||||
gap_y: float,
|
||||
cols: int,
|
||||
) -> list[tuple[float, float]]:
|
||||
kind = str(layout or "grid").strip().lower() or "grid"
|
||||
if count <= 0:
|
||||
return []
|
||||
if kind == "stack":
|
||||
return [(float(origin_x), float(origin_y) + i * float(gap_y)) for i in range(count)]
|
||||
c = int(cols or 0)
|
||||
if c <= 0:
|
||||
c = max(1, int(count**0.5) or 1)
|
||||
return [
|
||||
(float(origin_x) + (i % c) * float(gap_x), float(origin_y) + (i // c) * float(gap_y))
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
def _view_node_count(db: Session, view_id: str) -> int:
|
||||
return int(
|
||||
db.query(func.count(TopoViewNode.id)).filter(TopoViewNode.view_id == view_id).scalar() or 0
|
||||
)
|
||||
|
||||
|
||||
def _mutation_result(
|
||||
db: Session,
|
||||
view_id: str,
|
||||
*,
|
||||
max_nodes: int,
|
||||
return_graph: bool,
|
||||
matched: int = 0,
|
||||
added: int = 0,
|
||||
updated: int = 0,
|
||||
removed: int = 0,
|
||||
skipped_existing: int = 0,
|
||||
skipped_missing: int = 0,
|
||||
skipped_locked: int = 0,
|
||||
truncated: bool = False,
|
||||
next_offset: int | None = None,
|
||||
) -> ViewMutationOut | TopologyViewGraphOut:
|
||||
if return_graph:
|
||||
return get_view_graph(db, view_id)
|
||||
return ViewMutationOut(
|
||||
ok=True,
|
||||
view_id=view_id,
|
||||
matched=matched,
|
||||
added=added,
|
||||
updated=updated,
|
||||
removed=removed,
|
||||
skipped_existing=skipped_existing,
|
||||
skipped_missing=skipped_missing,
|
||||
skipped_locked=skipped_locked,
|
||||
view_node_count=_view_node_count(db, view_id),
|
||||
max_nodes=max_nodes,
|
||||
truncated=truncated,
|
||||
next_offset=next_offset,
|
||||
graph=None,
|
||||
)
|
||||
|
||||
|
||||
def patch_view_positions(
|
||||
db: Session, view_id: str, body: ViewPositionsPatch
|
||||
) -> TopologyViewGraphOut:
|
||||
) -> ViewMutationOut | TopologyViewGraphOut:
|
||||
view = _get_view_or_404(db, view_id)
|
||||
mem = _membership_for_view(view)
|
||||
max_nodes = int(mem.get("max_nodes") or 300)
|
||||
now = _utcnow()
|
||||
positions = list(body.positions or [])
|
||||
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
|
||||
raise HTTPException(status_code=400, detail="too_many_positions")
|
||||
layout = str(body.layout or "").strip().lower()
|
||||
updated = 0
|
||||
skipped_locked = 0
|
||||
matched = 0
|
||||
|
||||
existing = {
|
||||
vn.fabric_node_id: vn
|
||||
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||
}
|
||||
for p in positions:
|
||||
fid = str(p.fabric_node_id or "").strip()
|
||||
if not fid:
|
||||
continue
|
||||
if db.get(TopoFabricNode, fid) is None:
|
||||
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
|
||||
row = existing.get(fid)
|
||||
if row is None:
|
||||
row = TopoViewNode(
|
||||
id=uuid4().hex,
|
||||
view_id=view.id,
|
||||
fabric_node_id=fid,
|
||||
x=float(p.x or 0),
|
||||
y=float(p.y or 0),
|
||||
label=str(p.label or "")[:256],
|
||||
locked=bool(p.locked),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
existing[fid] = row
|
||||
|
||||
if layout in {"grid", "offset", "stack"}:
|
||||
ids = select_view_fabric_ids(
|
||||
db,
|
||||
view.id,
|
||||
fabric_node_ids=list(body.fabric_node_ids or []),
|
||||
keyword=body.keyword,
|
||||
role=body.role,
|
||||
vendor=body.vendor,
|
||||
link_status=body.link_status,
|
||||
)
|
||||
if not ids and not _has_fabric_filter(
|
||||
keyword=body.keyword,
|
||||
role=body.role,
|
||||
vendor=body.vendor,
|
||||
link_status=body.link_status,
|
||||
) and not (body.fabric_node_ids or []):
|
||||
# layout with no filter/ids → all nodes on view
|
||||
ids = sorted(existing.keys())
|
||||
matched = len(ids)
|
||||
if layout == "offset":
|
||||
for fid in ids:
|
||||
row = existing.get(fid)
|
||||
if row is None:
|
||||
continue
|
||||
if row.locked:
|
||||
skipped_locked += 1
|
||||
continue
|
||||
row.x = float(row.x or 0) + float(body.dx or 0)
|
||||
row.y = float(row.y or 0) + float(body.dy or 0)
|
||||
row.updated_at = now
|
||||
updated += 1
|
||||
else:
|
||||
if row.locked and not p.locked:
|
||||
# allow unlock + move when explicitly unlocked in patch
|
||||
pass
|
||||
if row.locked and bool(p.locked):
|
||||
coords = _layout_coords(
|
||||
len(ids),
|
||||
layout=layout,
|
||||
origin_x=float(body.origin_x),
|
||||
origin_y=float(body.origin_y),
|
||||
gap_x=float(body.gap_x),
|
||||
gap_y=float(body.gap_y),
|
||||
cols=int(body.cols or 0),
|
||||
)
|
||||
for fid, (x, y) in zip(ids, coords):
|
||||
row = existing.get(fid)
|
||||
if row is None:
|
||||
continue
|
||||
if row.locked:
|
||||
skipped_locked += 1
|
||||
continue
|
||||
row.x = x
|
||||
row.y = y
|
||||
row.updated_at = now
|
||||
updated += 1
|
||||
else:
|
||||
positions = list(body.positions or [])
|
||||
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
|
||||
raise HTTPException(status_code=400, detail="too_many_positions")
|
||||
matched = len(positions)
|
||||
for p in positions:
|
||||
fid = str(p.fabric_node_id or "").strip()
|
||||
if not fid:
|
||||
continue
|
||||
row.x = float(p.x or 0)
|
||||
row.y = float(p.y or 0)
|
||||
if p.label is not None:
|
||||
row.label = str(p.label or "")[:256]
|
||||
row.locked = bool(p.locked)
|
||||
row.updated_at = now
|
||||
if db.get(TopoFabricNode, fid) is None:
|
||||
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
|
||||
row = existing.get(fid)
|
||||
if row is None:
|
||||
row = TopoViewNode(
|
||||
id=uuid4().hex,
|
||||
view_id=view.id,
|
||||
fabric_node_id=fid,
|
||||
x=float(p.x or 0),
|
||||
y=float(p.y or 0),
|
||||
label=str(p.label or "")[:256],
|
||||
locked=bool(p.locked),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
existing[fid] = row
|
||||
updated += 1
|
||||
else:
|
||||
if row.locked and bool(p.locked):
|
||||
skipped_locked += 1
|
||||
continue
|
||||
row.x = float(p.x or 0)
|
||||
row.y = float(p.y or 0)
|
||||
if p.label is not None:
|
||||
row.label = str(p.label or "")[:256]
|
||||
row.locked = bool(p.locked)
|
||||
row.updated_at = now
|
||||
updated += 1
|
||||
|
||||
view.updated_at = now
|
||||
db.commit()
|
||||
return get_view_graph(db, view.id)
|
||||
return _mutation_result(
|
||||
db,
|
||||
view.id,
|
||||
max_nodes=max_nodes,
|
||||
return_graph=bool(body.return_graph),
|
||||
matched=matched,
|
||||
updated=updated,
|
||||
skipped_locked=skipped_locked,
|
||||
)
|
||||
|
||||
|
||||
def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> TopologyViewGraphOut:
|
||||
def add_nodes_to_view(
|
||||
db: Session, view_id: str, body: ViewNodesAdd
|
||||
) -> ViewMutationOut | TopologyViewGraphOut:
|
||||
view = _get_view_or_404(db, view_id)
|
||||
mem = _membership_for_view(view)
|
||||
max_nodes = int(mem.get("max_nodes") or 300)
|
||||
|
|
@ -367,9 +631,51 @@ def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> Topology
|
|||
vn.fabric_node_id
|
||||
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
|
||||
}
|
||||
if len(existing) >= max_nodes:
|
||||
raise HTTPException(status_code=400, detail="membership_max_nodes")
|
||||
added_ids: list[str] = []
|
||||
original_count = len(existing)
|
||||
if original_count >= max_nodes:
|
||||
if body.return_graph:
|
||||
raise HTTPException(status_code=400, detail="membership_max_nodes")
|
||||
return ViewMutationOut(
|
||||
ok=False,
|
||||
view_id=view.id,
|
||||
matched=0,
|
||||
view_node_count=original_count,
|
||||
max_nodes=max_nodes,
|
||||
truncated=True,
|
||||
)
|
||||
|
||||
candidate_ids: list[str] = []
|
||||
matched_total = 0
|
||||
next_offset: int | None = None
|
||||
filter_mode = _has_fabric_filter(
|
||||
keyword=body.keyword,
|
||||
role=body.role,
|
||||
vendor=body.vendor,
|
||||
link_status=body.link_status,
|
||||
)
|
||||
|
||||
if filter_mode:
|
||||
page_ids, matched_total = select_fabric_ids(
|
||||
db,
|
||||
keyword=body.keyword,
|
||||
role=body.role,
|
||||
vendor=body.vendor,
|
||||
link_status=body.link_status,
|
||||
offset=int(body.offset or 0),
|
||||
limit=int(body.limit or 500),
|
||||
)
|
||||
candidate_ids.extend(page_ids)
|
||||
end = int(body.offset or 0) + len(page_ids)
|
||||
if end < matched_total:
|
||||
next_offset = end
|
||||
else:
|
||||
for fid in body.fabric_node_ids or []:
|
||||
fid_s = str(fid or "").strip()
|
||||
if fid_s:
|
||||
candidate_ids.append(fid_s)
|
||||
matched_total = len(candidate_ids)
|
||||
|
||||
# UI path: managed / ume may still create fabric nodes.
|
||||
for mid in body.managed_ne_ids or []:
|
||||
mid_s = str(mid or "").strip()
|
||||
if not mid_s:
|
||||
|
|
@ -378,9 +684,8 @@ def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> Topology
|
|||
if ne is None:
|
||||
continue
|
||||
fn = ensure_fabric_node_for_managed(db, ne)
|
||||
if fn.id not in existing:
|
||||
added_ids.append(fn.id)
|
||||
existing.add(fn.id)
|
||||
candidate_ids.append(fn.id)
|
||||
matched_total += 1
|
||||
default_profile = get_default_profile(db)
|
||||
for uid in body.ume_ne_ids or []:
|
||||
uid_s = str(uid or "").strip()
|
||||
|
|
@ -394,26 +699,56 @@ def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> Topology
|
|||
else:
|
||||
dtype, vendor = "zte_zxros", (ume.vendor or "ZTE")
|
||||
fn = ensure_fabric_node_for_ume(db, ume, device_type=dtype, vendor=vendor)
|
||||
if fn.id not in existing:
|
||||
added_ids.append(fn.id)
|
||||
existing.add(fn.id)
|
||||
for fid in body.fabric_node_ids or []:
|
||||
fid_s = str(fid or "").strip()
|
||||
if not fid_s or fid_s in existing:
|
||||
candidate_ids.append(fn.id)
|
||||
matched_total += 1
|
||||
|
||||
# Dedupe preserve order
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for fid in candidate_ids:
|
||||
if fid in seen:
|
||||
continue
|
||||
if db.get(TopoFabricNode, fid_s) is None:
|
||||
seen.add(fid)
|
||||
ordered.append(fid)
|
||||
|
||||
skipped_existing = 0
|
||||
skipped_missing = 0
|
||||
to_add: list[str] = []
|
||||
for fid in ordered:
|
||||
if fid in existing:
|
||||
skipped_existing += 1
|
||||
continue
|
||||
added_ids.append(fid_s)
|
||||
existing.add(fid_s)
|
||||
# `existing` already includes ids in added_ids; cap new placements.
|
||||
original_count = len(existing) - len(added_ids)
|
||||
if db.get(TopoFabricNode, fid) is None:
|
||||
skipped_missing += 1
|
||||
continue
|
||||
to_add.append(fid)
|
||||
|
||||
room = max(0, max_nodes - original_count)
|
||||
if len(added_ids) > room:
|
||||
added_ids = added_ids[:room]
|
||||
cols = max(1, int(len(added_ids) ** 0.5) or 1)
|
||||
for i, fid in enumerate(added_ids):
|
||||
x = (i % cols) * 180.0 + 40.0
|
||||
y = (i // cols) * 120.0 + 40.0
|
||||
truncated = len(to_add) > room
|
||||
if truncated:
|
||||
to_add = to_add[:room]
|
||||
next_offset = None # capped by membership; caller should open another view
|
||||
|
||||
keep_layout = str(body.layout or "grid").strip().lower() == "keep"
|
||||
if keep_layout:
|
||||
coords = [(40.0, 40.0)] * len(to_add)
|
||||
else:
|
||||
# Place new nodes to the right of existing content when possible.
|
||||
max_x = max((float(vn.x or 0) for vn in db.query(TopoViewNode).filter(
|
||||
TopoViewNode.view_id == view.id
|
||||
).all()), default=40.0)
|
||||
origin_x = (max_x + 200.0) if original_count else 40.0
|
||||
coords = _layout_coords(
|
||||
len(to_add),
|
||||
layout="grid",
|
||||
origin_x=origin_x,
|
||||
origin_y=40.0,
|
||||
gap_x=180.0,
|
||||
gap_y=120.0,
|
||||
cols=0,
|
||||
)
|
||||
|
||||
for fid, (x, y) in zip(to_add, coords):
|
||||
db.add(
|
||||
TopoViewNode(
|
||||
id=uuid4().hex,
|
||||
|
|
@ -427,9 +762,26 @@ def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> Topology
|
|||
updated_at=now,
|
||||
)
|
||||
)
|
||||
view.updated_at = now
|
||||
db.commit()
|
||||
return get_view_graph(db, view.id)
|
||||
existing.add(fid)
|
||||
|
||||
if to_add:
|
||||
view.updated_at = now
|
||||
db.commit()
|
||||
elif filter_mode or body.fabric_node_ids or body.managed_ne_ids or body.ume_ne_ids:
|
||||
db.commit()
|
||||
|
||||
return _mutation_result(
|
||||
db,
|
||||
view.id,
|
||||
max_nodes=max_nodes,
|
||||
return_graph=bool(body.return_graph),
|
||||
matched=matched_total if filter_mode else len(ordered),
|
||||
added=len(to_add),
|
||||
skipped_existing=skipped_existing,
|
||||
skipped_missing=skipped_missing,
|
||||
truncated=truncated or (next_offset is not None),
|
||||
next_offset=next_offset,
|
||||
)
|
||||
|
||||
|
||||
def _neighbor_ids(
|
||||
|
|
@ -613,16 +965,59 @@ def populate_view(db: Session, view_id: str, body: ViewPopulateRequest) -> ViewP
|
|||
)
|
||||
|
||||
|
||||
def remove_view_nodes(db: Session, view_id: str, fabric_node_ids: list[str]) -> TopologyViewGraphOut:
|
||||
def remove_view_nodes(
|
||||
db: Session,
|
||||
view_id: str,
|
||||
fabric_node_ids: list[str] | ViewNodesRemove | None = None,
|
||||
*,
|
||||
body: ViewNodesRemove | None = None,
|
||||
) -> ViewMutationOut | TopologyViewGraphOut:
|
||||
"""Remove placements. Accept ViewNodesRemove, or a legacy list of fabric ids."""
|
||||
view = _get_view_or_404(db, view_id)
|
||||
ids = [str(x).strip() for x in (fabric_node_ids or []) if str(x).strip()]
|
||||
mem = _membership_for_view(view)
|
||||
max_nodes = int(mem.get("max_nodes") or 300)
|
||||
|
||||
if isinstance(fabric_node_ids, ViewNodesRemove):
|
||||
req = fabric_node_ids
|
||||
elif body is not None:
|
||||
req = body
|
||||
else:
|
||||
req = ViewNodesRemove(fabric_node_ids=list(fabric_node_ids or []), return_graph=True)
|
||||
|
||||
filter_mode = _has_fabric_filter(
|
||||
keyword=req.keyword, role=req.role, vendor=req.vendor, link_status=req.link_status
|
||||
)
|
||||
explicit = [str(x).strip() for x in (req.fabric_node_ids or []) if str(x).strip()]
|
||||
if filter_mode or explicit:
|
||||
ids = select_view_fabric_ids(
|
||||
db,
|
||||
view.id,
|
||||
fabric_node_ids=explicit or None,
|
||||
keyword=req.keyword,
|
||||
role=req.role,
|
||||
vendor=req.vendor,
|
||||
link_status=req.link_status,
|
||||
)
|
||||
else:
|
||||
ids = []
|
||||
|
||||
removed = 0
|
||||
if ids:
|
||||
db.query(TopoViewNode).filter(
|
||||
TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id.in_(ids)
|
||||
).delete(synchronize_session=False)
|
||||
removed = (
|
||||
db.query(TopoViewNode)
|
||||
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id.in_(ids))
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
view.updated_at = _utcnow()
|
||||
db.commit()
|
||||
return get_view_graph(db, view.id)
|
||||
return _mutation_result(
|
||||
db,
|
||||
view.id,
|
||||
max_nodes=max_nodes,
|
||||
return_graph=bool(req.return_graph),
|
||||
matched=len(ids),
|
||||
removed=int(removed or 0),
|
||||
)
|
||||
|
||||
|
||||
_HEX_COLOR_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue