From 2d92dde8e52dc8cd5603bfa8919e56ca62ac52f2 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 4 Aug 2026 07:39:14 +0800 Subject: [PATCH] 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 --- .cursor/skills/netx-topology/SKILL.md | 54 +- docs/MCP_TOPOLOGY.md | 12 +- netx_api/auth_router.py | 43 ++ netx_api/auth_schemas.py | 8 +- netx_api/auth_service.py | 41 ++ netx_api/topology_router.py | 6 +- netx_api/topology_schemas.py | 57 +- netx_api/topology_views_graph.py | 533 +++++++++++++++--- packages/netx-topology-mcp/README.md | 2 +- .../src/netx_topology_mcp/http_tools.py | 215 ++++++- .../tests/test_mcp_topology.py | 37 +- tests/test_auth.py | 24 + tests/test_topology.py | 68 +++ web/src/i18n/en.ts | 35 +- web/src/i18n/zh.ts | 32 +- web/src/index.css | 159 ++++++ web/src/pages/ApiTokensPage.tsx | 198 ++++--- web/src/pages/TopologyPage.tsx | 27 +- 18 files changed, 1294 insertions(+), 257 deletions(-) diff --git a/.cursor/skills/netx-topology/SKILL.md b/.cursor/skills/netx-topology/SKILL.md index 7f01755..aa86844 100644 --- a/.cursor/skills/netx-topology/SKILL.md +++ b/.cursor/skills/netx-topology/SKILL.md @@ -1,9 +1,7 @@ --- name: netx-topology description: >- - 用 netx-topology MCP 查询 Fabric 链路、建拓扑画布并安全摆点(不污染 Fabric)。 - 触发:画拓扑/拓扑图/拓扑画布、LLDP 邻居/链路、Fabric 网元搜索、createTopologyView / - addTopologyViewNodes、netx-topology、看着 MCP 画图。须先读本 skill 再调 MCP。 + 用 netx-topology MCP 查链路、画拓扑(不污染 Fabric)。触发:画拓扑、LLDP、Fabric、netx-topology。先读再调。 user-invocable: true disable-model-invocation: false --- @@ -16,47 +14,45 @@ disable-model-invocation: false ## 硬规则 -1. **先读后写**:任何建图/摆点前先 `getTopologyTree`;改已有图前先 `getTopologyView`。 -2. **只放已有 Fabric 节点**:`addTopologyViewNodes` **仅** `fabric_node_ids`。禁止臆造 id;禁止试图传 `managed_ne_ids` / `ume_ne_ids`(会被拒)。 -3. **不污染 Fabric**:本 MCP **不能**手工建链、不能 `populate`、不能删 Fabric / 整图。链路来自已有 LLDP/手工边;邻居用 `projectTopologyNeighbors`。 -4. **写权限**:画图工具要 token 含 `ne:write`。若 `tools/list` 没有写工具 → 停下来告诉用户去 **系统 → API Key** 用「MCP + 拓扑写」签发,并配置 `NETX_API_TOKEN` 后 Sync Tools。勿假装已画成功。 -5. **无文件夹则停**:`createTopologyView` 需要已有 `folder_id`(region)。树里没有可用 folder 时,请用户先在网页建区域,或改挂到已有 region;**MCP 不能新建 region/folder**。 -6. **批量克制**:单次 `addTopologyViewNodes` 控制在合理数量(优先先搜再加);大图用多次调用 + `projectTopologyNeighbors` 扩展。 +1. **先读后写**:任何建图/摆点前先 `getTopologyTree`;改已有图前可 `getTopologyView`(大图慎拉整图)。 +2. **筛选交给 API**:加/挪/删优先传 `keyword` / `role` / `vendor` / `link_status`(加节点再用 `limit`/`offset`)。**不要**先 list 全量再回传成千上万 id。 +3. **只动已有 Fabric**:禁止 `managed_ne_ids` / `ume_ne_ids`;禁止臆造 fabric id。 +4. **不污染 Fabric**:不能手工建链、不能 populate、不能删 Fabric / 整图。邻居用 `projectTopologyNeighbors`。 +5. **写权限**:需要 `ne:write`。tools/list 没有写工具 → 停,让用户用「MCP + 拓扑写」签发 Token。 +6. **无 folder 则停**:`createTopologyView` 需要已有 `folder_id`;MCP 不能新建 region。 +7. **单画布硬顶 2000**:满了 `truncated` / 触顶 → 新建另一张画布继续;全网五万设备靠多画布切片。 ## 推荐流水线(从零画一张图) ``` -1 getTopologyTree → 选 folder_id(region) -2 searchTopologyFabricNodes / listTopologyFabricNodes → 拿到 fabric_node_ids -3 createTopologyView → name + folder_id → 得到 view_id -4 addTopologyViewNodes → view_id + fabric_node_ids(layout=grid) -5 projectTopologyNeighbors → 把已有 LLDP 邻居投影上画布(可重复) -6 (可选)updateTopologyViewPositions → 微调坐标 -7 getTopologyView → 向用户确认节点/边数量 +1 getTopologyTree → folder_id +2 createTopologyView → view_id +3 addTopologyViewNodes(keyword=…, limit) → 看 added / next_offset,循环 offset 直到无更多或满 cap +4 projectTopologyNeighbors → 可选 +5 updateTopologyViewPositions(layout=grid|offset|stack, keyword=…) → API 自己筛并摆 +6 (少量微调才用 positions[]) ``` -查链路不画图时:`queryTopologyEdges`(带 `node_id` 看 `peer_count`)或 `queryTopologyNeighborhood`。 +查链路不画图:`queryTopologyEdges` / `queryTopologyNeighborhood`。 ## 工具速查 | 目的 | 工具 | |------|------| -| 树 / region / 已有画布 | `getTopologyTree`, `listTopologyViews` | -| 读一图画布 | `getTopologyView` | -| 新建画布 | `createTopologyView` | -| 摆点 / 移除(仅画布) | `addTopologyViewNodes`, `removeTopologyViewNodes` | -| 摆坐标 | `updateTopologyViewPositions` | -| 投影 LLDP 邻居 | `projectTopologyNeighbors` | -| 搜 Fabric | `searchTopologyFabricNodes`, `listTopologyFabricNodes` | -| 汇总 / 邻接 / 边 | `getTopologyFabricSummary`, `queryTopologyNeighborhood`, `queryTopologyEdges` | +| 树 / 画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` | +| 筛选批量加 | `addTopologyViewNodes`(filters + limit/offset) | +| 筛选批量挪 | `updateTopologyViewPositions`(layout + filters) | +| 筛选批量删 | `removeTopologyViewNodes`(filters) | +| 投影邻居 | `projectTopologyNeighbors` | +| Fabric 读 | `search…` / `list…` / `queryTopologyEdges` / `…Neighborhood` / summary | ## 对人说清楚 -- 网页观看:拓扑页左侧或浏览区开 **「实时同步」**(默认关);**不必先打开某张图**也能看到新建画布。 -- 回报时给出:`view_id`、画布名、folder、节点数;写失败则原样报 scope/API 错误。 +- 网页观看:拓扑页开 **「实时同步」**(默认关)。 +- 回报:`view_id`、画布名、`added`/`updated`/`removed`、是否 `truncated`/`next_offset`。 ## 不要做 -- 不要用 `netx`(告警 MCP)冒充拓扑写接口。 -- 不要为「画上设备」去改 managed-NE / 造假 Fabric。 +- 不要用 `netx` 告警 MCP 冒充拓扑写。 +- 不要为画图去造 Fabric / 改 managed-NE。 - 不要在未确认 folder/view 时连环盲写。 diff --git a/docs/MCP_TOPOLOGY.md b/docs/MCP_TOPOLOGY.md index 9b0f8fb..3b9ea05 100644 --- a/docs/MCP_TOPOLOGY.md +++ b/docs/MCP_TOPOLOGY.md @@ -81,16 +81,18 @@ oclaw:Install from JSON → Health → Sync Tools(应看到 **13** 个工具 | 工具 | 作用 | |------|------| | `createTopologyView` | 在 folder 下新建画布 | -| `addTopologyViewNodes` | **仅** `fabric_node_ids` 放到画布(拒绝 managed/UME,避免创建 Fabric 占位) | -| `removeTopologyViewNodes` | 从画布移除(不删 Fabric) | -| `updateTopologyViewPositions` | 设置坐标 | +| `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 | +| `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 | +| `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 | | `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布 | -**刻意不提供:** 手工建链、`populate`(会经 managed 创建 Fabric 占位)、删 Fabric / 删整图。 +**单画布硬顶 2000**;库存更大时多画布 + `offset` 翻页加满。前端对可见节点做 `onlyRenderVisibleElements`。 + +**刻意不提供:** 手工建链、`populate`、删 Fabric / 删整图。 写操作需要 token 具备 `ne:write`;只读为 `ne:read`。 -**权限怎么开:** 网页 **系统 → API Key**(`/api-keys`)创建 Key 时勾选 scopes,或点「MCP + 拓扑写」。默认 bootstrap `data/auth/mcp_token` **没有** `ne:write`,Agent 的 `tools/list` **不会出现**写工具。把新 Key 配到 `NETX_API_TOKEN`(或写进 MCP env)后重启 MCP / Sync Tools。 +**权限怎么开:** 网页 **系统 → API Key**(`/api-keys`)。新建默认已含 `ne:write`(可关掉);已有 Key 在 **操作 → 改权限**。把明文配到 `NETX_API_TOKEN` 后重启 MCP / Sync Tools。仓库自动生成的 `data/auth/mcp_token` 仍是只读+CLI(无写),需要画图请另建 Key 或改权限。 **前端能否看着画:** 在拓扑页左侧树或右侧浏览区点 **「实时同步」**(默认关闭;**不需要先打开某张图**)。开启后树约每 5 秒、已打开的图约每 3 秒拉取,可看到 MCP 新建区域/画布并往上加点。有未保存本地拖动时不会覆盖你的编辑。 diff --git a/netx_api/auth_router.py b/netx_api/auth_router.py index c15d38b..fd346ed 100644 --- a/netx_api/auth_router.py +++ b/netx_api/auth_router.py @@ -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, diff --git a/netx_api/auth_schemas.py b/netx_api/auth_schemas.py index 404673e..06fd8fb 100644 --- a/netx_api/auth_schemas.py +++ b/netx_api/auth_schemas.py @@ -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 diff --git a/netx_api/auth_service.py b/netx_api/auth_service.py index 9fb36a7..f4eb15a 100644 --- a/netx_api/auth_service.py +++ b/netx_api/auth_service.py @@ -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 = ( diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index f1cd8a3..7da828e 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -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") diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index b9576d9..3dbcb6a 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -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): diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index c4d50de..a390b9e 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -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})$") diff --git a/packages/netx-topology-mcp/README.md b/packages/netx-topology-mcp/README.md index 93fac0c..13d13cb 100644 --- a/packages/netx-topology-mcp/README.md +++ b/packages/netx-topology-mcp/README.md @@ -33,7 +33,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx | 类别 | 工具 | |------|------| | 树/画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` | -| 画图 | `addTopologyViewNodes`(仅已有 `fabric_node_ids`), `removeTopologyViewNodes`, `updateTopologyViewPositions`, `projectTopologyNeighbors` | +| 画图 | `addTopologyViewNodes` / `remove…` / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` | | Fabric 只读 | `getTopologyFabricSummary`, `listTopologyFabricNodes`, `searchTopologyFabricNodes`, `queryTopologyNeighborhood`, `queryTopologyEdges` | **安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py index 8a7c10b..a4684f9 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py @@ -57,49 +57,152 @@ def _create_topology_view(args: dict[str, Any]) -> dict[str, Any]: return _data(http_json("POST", "/v1/topology/views", body=body)) +_CHUNK = 500 + + +def _filter_fields(args: dict[str, Any]) -> dict[str, str]: + out: dict[str, str] = {} + for key in ("keyword", "role", "vendor", "link_status"): + val = str(args.get(key) or "").strip() + if val: + out[key] = val + return out + + +def _has_filter(args: dict[str, Any]) -> bool: + return bool(_filter_fields(args)) + + +def _merge_mutation_summaries(parts: list[dict[str, Any]]) -> dict[str, Any]: + if not parts: + return {"ok": False, "error": "empty_batch"} + if len(parts) == 1: + return parts[0] + base = dict(parts[-1]) + for key in ( + "matched", + "added", + "updated", + "removed", + "skipped_existing", + "skipped_missing", + "skipped_locked", + ): + base[key] = sum(int(p.get(key) or 0) for p in parts) + base["truncated"] = any(bool(p.get("truncated")) for p in parts) + # Keep last next_offset / view_node_count / max_nodes from final chunk. + base["ok"] = all(bool(p.get("ok", True)) for p in parts) and not any( + str(p.get("error") or "") for p in parts + ) + return base + + def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]: - """Place existing fabric nodes on a view only — never create fabric placeholders.""" + """Place existing fabric nodes on a view — prefer server-side filters over id lists.""" view_id = str(args.get("view_id") or "").strip() if not view_id: return {"ok": False, "error": "view_id_required"} - # Reject inventory-id shortcuts that would call ensure_fabric_node_* on the API. if args.get("managed_ne_ids") or args.get("ume_ne_ids"): return { "ok": False, "error": "fabric_nodes_only", - "detail": "Only fabric_node_ids are allowed; resolve inventory via search/list first.", + "detail": "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.", } + filters = _filter_fields(args) fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()] - if not fabric_ids: - return {"ok": False, "error": "fabric_node_ids_required"} - body: dict[str, Any] = { - "managed_ne_ids": [], - "ume_ne_ids": [], - "fabric_node_ids": fabric_ids, - "layout": str(args.get("layout") or "grid").strip() or "grid", - } - return _data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body)) + if not filters and not fabric_ids: + return { + "ok": False, + "error": "filter_or_fabric_node_ids_required", + "detail": "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.", + } + + layout = str(args.get("layout") or "grid").strip() or "grid" + if filters: + body: dict[str, Any] = { + "managed_ne_ids": [], + "ume_ne_ids": [], + "fabric_node_ids": [], + "layout": layout, + "limit": min(2000, max(1, int(args.get("limit") or 500))), + "offset": max(0, int(args.get("offset") or 0)), + "return_graph": False, + **filters, + } + return _data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body)) + + # Explicit ids: chunk to avoid huge payloads. + parts: list[dict[str, Any]] = [] + for i in range(0, len(fabric_ids), _CHUNK): + chunk = fabric_ids[i : i + _CHUNK] + body = { + "managed_ne_ids": [], + "ume_ne_ids": [], + "fabric_node_ids": chunk, + "layout": layout, + "return_graph": False, + } + parts.append(_data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body))) + return _merge_mutation_summaries(parts) def _remove_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]: view_id = str(args.get("view_id") or "").strip() - ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()] if not view_id: return {"ok": False, "error": "view_id_required"} - if not ids: - return {"ok": False, "error": "fabric_node_ids_required"} - return _data( - http_json("POST", f"/v1/topology/views/{view_id}/nodes/remove", body={"fabric_node_ids": ids}) - ) + filters = _filter_fields(args) + ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()] + if not filters and not ids: + return {"ok": False, "error": "filter_or_fabric_node_ids_required"} + if filters: + body: dict[str, Any] = {"fabric_node_ids": ids, "return_graph": False, **filters} + return _data(http_json("POST", f"/v1/topology/views/{view_id}/nodes/remove", body=body)) + parts: list[dict[str, Any]] = [] + for i in range(0, len(ids), _CHUNK): + chunk = ids[i : i + _CHUNK] + parts.append( + _data( + http_json( + "POST", + f"/v1/topology/views/{view_id}/nodes/remove", + body={"fabric_node_ids": chunk, "return_graph": False}, + ) + ) + ) + return _merge_mutation_summaries(parts) def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]: view_id = str(args.get("view_id") or "").strip() - positions = args.get("positions") if not view_id: return {"ok": False, "error": "view_id_required"} + layout = str(args.get("layout") or "").strip().lower() + filters = _filter_fields(args) + fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()] + positions = args.get("positions") + + if layout in {"grid", "offset", "stack"}: + body: dict[str, Any] = { + "layout": layout, + "origin_x": float(args.get("origin_x") if args.get("origin_x") is not None else 40), + "origin_y": float(args.get("origin_y") if args.get("origin_y") is not None else 40), + "gap_x": float(args.get("gap_x") if args.get("gap_x") is not None else 180), + "gap_y": float(args.get("gap_y") if args.get("gap_y") is not None else 120), + "cols": int(args.get("cols") or 0), + "dx": float(args.get("dx") or 0), + "dy": float(args.get("dy") or 0), + "fabric_node_ids": fabric_ids, + "return_graph": False, + **filters, + } + return _data(http_json("PATCH", f"/v1/topology/views/{view_id}/positions", body=body)) + if not isinstance(positions, list) or not positions: - return {"ok": False, "error": "positions_required"} + return { + "ok": False, + "error": "layout_or_positions_required", + "detail": "Pass layout=grid|offset|stack with optional filters, or positions[].", + } cleaned: list[dict[str, Any]] = [] for p in positions: if not isinstance(p, dict): @@ -118,7 +221,19 @@ def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]: ) if not cleaned: return {"ok": False, "error": "positions_required"} - return _data(http_json("PATCH", f"/v1/topology/views/{view_id}/positions", body={"positions": cleaned})) + parts: list[dict[str, Any]] = [] + for i in range(0, len(cleaned), _CHUNK): + chunk = cleaned[i : i + _CHUNK] + parts.append( + _data( + http_json( + "PATCH", + f"/v1/topology/views/{view_id}/positions", + body={"positions": chunk, "return_graph": False}, + ) + ) + ) + return _merge_mutation_summaries(parts) def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]: @@ -267,40 +382,79 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ { "name": "addTopologyViewNodes", "description": ( - "Place existing fabric nodes onto a view canvas (layout=grid|keep). " - "Only fabric_node_ids — never creates fabric placeholders from managed/UME ids." + "Bulk-place existing fabric nodes on a view. Prefer server filters " + "(keyword/role/vendor/link_status + limit/offset); API selects ids — do not pull then re-send huge id lists. " + "Returns a summary (added/truncated/next_offset). Canvas hard cap 2000. Never pass managed/UME ids." ), "inputSchema": { "type": "object", "properties": { "view_id": {"type": "string"}, - "fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "keyword": {"type": "string"}, + "role": {"type": "string"}, + "vendor": {"type": "string"}, + "link_status": { + "type": "string", + "enum": ["linked", "orphaned", "managed", "ume", "both"], + }, + "limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 500}, + "offset": {"type": "integer", "minimum": 0, "default": 0}, + "fabric_node_ids": {"type": "array", "items": {"type": "string"}}, "layout": {"type": "string", "enum": ["grid", "keep"], "default": "grid"}, }, - "required": ["view_id", "fabric_node_ids"], + "required": ["view_id"], "additionalProperties": False, }, }, { "name": "removeTopologyViewNodes", - "description": "Remove fabric nodes from a view canvas (does not delete fabric inventory).", + "description": ( + "Remove placements from a view (not fabric). Prefer filters (keyword/role/vendor/link_status) " + "so the API selects matches; or pass fabric_node_ids. Returns a summary." + ), "inputSchema": { "type": "object", "properties": { "view_id": {"type": "string"}, - "fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "keyword": {"type": "string"}, + "role": {"type": "string"}, + "vendor": {"type": "string"}, + "link_status": { + "type": "string", + "enum": ["linked", "orphaned", "managed", "ume", "both"], + }, + "fabric_node_ids": {"type": "array", "items": {"type": "string"}}, }, - "required": ["view_id", "fabric_node_ids"], + "required": ["view_id"], "additionalProperties": False, }, }, { "name": "updateTopologyViewPositions", - "description": "Set x/y positions for fabric nodes on a view (draw / rearrange).", + "description": ( + "Move nodes on a view. Prefer layout=grid|offset|stack with optional filters " + "(API selects matches and computes coords). Use positions[] only for small manual tweaks. Returns a summary." + ), "inputSchema": { "type": "object", "properties": { "view_id": {"type": "string"}, + "layout": {"type": "string", "enum": ["grid", "offset", "stack"]}, + "keyword": {"type": "string"}, + "role": {"type": "string"}, + "vendor": {"type": "string"}, + "link_status": { + "type": "string", + "enum": ["linked", "orphaned", "managed", "ume", "both"], + }, + "fabric_node_ids": {"type": "array", "items": {"type": "string"}}, + "origin_x": {"type": "number", "default": 40}, + "origin_y": {"type": "number", "default": 40}, + "gap_x": {"type": "number", "default": 180}, + "gap_y": {"type": "number", "default": 120}, + "cols": {"type": "integer", "minimum": 0, "default": 0}, + "dx": {"type": "number", "default": 0}, + "dy": {"type": "number", "default": 0}, "positions": { "type": "array", "items": { @@ -315,10 +469,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "required": ["fabric_node_id"], "additionalProperties": False, }, - "minItems": 1, }, }, - "required": ["view_id", "positions"], + "required": ["view_id"], "additionalProperties": False, }, }, diff --git a/packages/netx-topology-mcp/tests/test_mcp_topology.py b/packages/netx-topology-mcp/tests/test_mcp_topology.py index 59b088a..1263f6e 100644 --- a/packages/netx-topology-mcp/tests/test_mcp_topology.py +++ b/packages/netx-topology-mcp/tests/test_mcp_topology.py @@ -35,7 +35,7 @@ def test_add_nodes_rejects_managed_ume_ids() -> None: def test_add_nodes_posts_fabric_ids_only() -> None: with patch("netx_topology_mcp.http_tools.http_json") as mock_http: - mock_http.return_value = {"ok": True, "data": {"nodes": []}} + mock_http.return_value = {"ok": True, "data": {"ok": True, "added": 2, "return_graph": False}} out = call_http_tool( "addTopologyViewNodes", {"view_id": "v1", "fabric_node_ids": ["f1", "f2"], "layout": "grid"}, @@ -44,10 +44,45 @@ def test_add_nodes_posts_fabric_ids_only() -> None: assert body["fabric_node_ids"] == ["f1", "f2"] assert body["managed_ne_ids"] == [] assert body["ume_ne_ids"] == [] + assert body["return_graph"] is False payload = json.loads(out["content"][0]["text"]) assert payload["ok"] is True +def test_add_nodes_filter_posts_without_ids() -> None: + with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + mock_http.return_value = { + "ok": True, + "data": {"ok": True, "added": 10, "matched": 40, "next_offset": 10, "truncated": True}, + } + out = call_http_tool( + "addTopologyViewNodes", + {"view_id": "v1", "keyword": "BJ-", "limit": 10, "offset": 0}, + ) + body = mock_http.call_args[1]["body"] + assert body["keyword"] == "BJ-" + assert body["fabric_node_ids"] == [] + assert body["return_graph"] is False + payload = json.loads(out["content"][0]["text"]) + assert payload["added"] == 10 + assert payload["next_offset"] == 10 + + +def test_update_positions_layout_filter() -> None: + with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + mock_http.return_value = {"ok": True, "data": {"ok": True, "updated": 5}} + out = call_http_tool( + "updateTopologyViewPositions", + {"view_id": "v1", "layout": "grid", "keyword": "core", "origin_x": 0, "origin_y": 0}, + ) + body = mock_http.call_args[1]["body"] + assert body["layout"] == "grid" + assert body["keyword"] == "core" + assert body["return_graph"] is False + payload = json.loads(out["content"][0]["text"]) + assert payload["updated"] == 5 + + def test_create_view_requires_folder() -> None: out = call_http_tool("createTopologyView", {"name": "map1"}) assert out.get("isError") is True diff --git a/tests/test_auth.py b/tests/test_auth.py index 8f58b4c..65ab0ec 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -257,6 +257,30 @@ class AuthApiTests(unittest.TestCase): # Token cannot escalate beyond listed scopes (admin owner still capped by token list). self.assertNotIn("admin:users", granted) + def test_api_token_update_scopes(self) -> None: + token = self._login() + created = self.client.post( + "/v1/api-tokens", + headers={"Authorization": f"Bearer {token}"}, + json={"name": "edit-me", "scopes": ["ne:read", "alarms:read"]}, + ) + self.assertEqual(created.status_code, 200, created.text) + tid = created.json()["token"]["id"] + patched = self.client.patch( + f"/v1/api-tokens/{tid}", + headers={"Authorization": f"Bearer {token}"}, + json={"scopes": ["ne:read", "ne:write", "alarms:read", "ne:exec"]}, + ) + self.assertEqual(patched.status_code, 200, patched.text) + scopes = sorted(patched.json()["token"].get("scopes") or []) + self.assertEqual(scopes, ["alarms:read", "ne:exec", "ne:read", "ne:write"]) + empty = self.client.patch( + f"/v1/api-tokens/{tid}", + headers={"Authorization": f"Bearer {token}"}, + json={"scopes": []}, + ) + self.assertEqual(empty.status_code, 400) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_topology.py b/tests/test_topology.py index e236bc6..9798f91 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -1526,6 +1526,74 @@ Management Addresses: self.assertIsNotNone(self.db.get(ManagedNE, real.id)) self.assertIsNotNone(self.db.get(ManagedNE, ph.id)) + def test_filter_bulk_add_layout_and_remove(self) -> None: + from netx_api.topology_schemas import ViewMutationOut, ViewNodesRemove + + suffix = uuid4().hex[:8] + region = self._region(f"Bulk-{suffix}") + view = svc.create_view( + self.db, + TopologyViewCreate(name=f"BulkV-{suffix}", folder_id=region), + ) + nodes = [] + for i in range(5): + n = TopoFabricNode( + id=f"bf-{suffix}-{i}", + name=f"BJ-SW-{suffix}-{i}", + ip=f"10.88.{i}.1", + vendor="Cisco", + role="access", + ) + self.db.add(n) + nodes.append(n) + other = TopoFabricNode( + id=f"bf-other-{suffix}", + name=f"SH-SW-{suffix}", + ip="10.89.0.1", + vendor="Huawei", + role="core", + ) + self.db.add(other) + self.db.commit() + + summary = svc.add_nodes_to_view( + self.db, + view.id, + ViewNodesAdd(keyword="BJ-SW-", limit=3, offset=0, return_graph=False), + ) + self.assertIsInstance(summary, ViewMutationOut) + assert isinstance(summary, ViewMutationOut) + self.assertEqual(summary.added, 3) + self.assertEqual(summary.matched, 5) + self.assertEqual(summary.next_offset, 3) + self.assertTrue(summary.truncated) + + more = svc.add_nodes_to_view( + self.db, + view.id, + ViewNodesAdd(keyword="BJ-SW-", limit=10, offset=3, return_graph=False), + ) + assert isinstance(more, ViewMutationOut) + self.assertEqual(more.added, 2) + self.assertIsNone(more.next_offset) + + laid = svc.patch_view_positions( + self.db, + view.id, + ViewPositionsPatch(layout="grid", keyword="BJ-SW-", origin_x=10, origin_y=20, return_graph=False), + ) + assert isinstance(laid, ViewMutationOut) + self.assertEqual(laid.updated, 5) + + removed = svc.remove_view_nodes( + self.db, + view.id, + body=ViewNodesRemove(keyword=f"BJ-SW-{suffix}-1", return_graph=False), + ) + assert isinstance(removed, ViewMutationOut) + self.assertGreaterEqual(removed.removed, 1) + self.assertLess(removed.view_node_count, 5) + if __name__ == "__main__": unittest.main() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 179f6c7..6b23609 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -11,6 +11,7 @@ const en = { pagerMeta: "{{total}} items · page {{page}}/{{pages}}", opFailed: "Operation failed", empty: "-", + cancel: "Cancel", }, workbench: { title: "Workbench", @@ -476,8 +477,8 @@ const en = { colStatus: "Status", colIp: "IP", apiKeysTitle: "API Key management", - apiKeysHint: - "Create long-lived tokens for MCP/scripts. The secret is shown only once. Admins can issue keys for other users. Pick scopes; topology drawing needs ne:write.", + apiKeysHelp: + "The web UI uses login sessions, not API keys — keys are for MCP/scripts. Defaults on create: alarms:read + ne:read + ne:write + ne:exec; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved for future MCP tools.", tokenName: "Name", expiresIn: "Expiry", expire7d: "7 days", @@ -489,6 +490,7 @@ const en = { tokenOwnerSelf: "Myself ({{user}})", createToken: "Create key", tokenCreated: "API key created", + tokenUpdated: "Scopes updated", tokenRevoked: "Revoked", tokenOnceHint: "Copy and store this secret now; it will not be shown again:", copyToken: "Copy", @@ -501,24 +503,21 @@ const en = { tokenStatusRevoked: "Revoked", revokeToken: "Revoke", revokeConfirm: "Revoke this API key?", - scopesTitle: "Scopes", - scopesHint: - "Scopes are stored on the token. Without Inherit, pick at least one. MCP default omits ne:write so write tools stay hidden from the agent.", - scopesInherit: "Inherit all owner scopes", - scopesInheritShort: "Inherit owner", + editScopes: "Edit scopes", + saveScopes: "Save scopes", + scopesUnsetLegacy: "Unrestricted (same as owner)", scopesCol: "Scopes", - scopesRequired: "Select at least one scope, or inherit all owner scopes", + scopesRequired: "Select at least one scope", scopesNoneAvailable: "This owner has no grantable scopes", - scopePresetMcp: "MCP default (read + CLI)", - scopePresetTopoWrite: "MCP + topology write", - scopeAlarmsRead: "alarms:read Alarms read", - scopeNeRead: "ne:read NE / topology read", - scopeNeWrite: "ne:write NE / topology write (draw)", - scopeNeExec: "ne:exec Managed NE exec", - scopeWebcrt: "webcrt:session WebCRT", - scopeSql: "sql:query SQL", - scopeAdminUsers: "admin:users User admin", - scopeOpsWrite: "ops:write Ops write", + scopeAlarmsRead: "alarms:read — Alarms read (netx MCP)", + scopeNeRead: "ne:read — NE / topology read (MCP)", + scopeNeWrite: "ne:write — NE / topology write / draw (MCP)", + scopeNeExec: "ne:exec — Managed NE CLI (netx MCP)", + scopeSql: "sql:query — UME SQL (netx MCP)", + scopeWebcrt: "webcrt:session — WebCRT (reserved MCP)", + scopeAdminUsers: "admin:users — User admin (reserved MCP)", + scopeOpsWrite: "ops:write — Ops write / subscriptions (reserved MCP)", + forceChangeTitle: "Change initial password", forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.", oldPassword: "Current password", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6b3c42c..ea3cd12 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -11,6 +11,7 @@ const zh = { pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页", opFailed: "操作失败", empty: "-", + cancel: "取消", }, workbench: { title: "工作台", @@ -473,7 +474,8 @@ const zh = { colStatus: "状态码", colIp: "IP", apiKeysTitle: "API Key 管理", - apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。可勾选权限;拓扑画图需 ne:write。", + apiKeysHelp: + "网页登录不用 API Key,Key 给 MCP/脚本用。生成时默认 alarms:read + ne:read + ne:write + ne:exec,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接 MCP 的项为预留。", tokenName: "名称", expiresIn: "有效期", expire7d: "7 天", @@ -485,6 +487,7 @@ const zh = { tokenOwnerSelf: "自己({{user}})", createToken: "生成 Key", tokenCreated: "API Key 已生成", + tokenUpdated: "权限已更新", tokenRevoked: "已吊销", tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:", copyToken: "复制", @@ -497,23 +500,20 @@ const zh = { tokenStatusRevoked: "已吊销", revokeToken: "吊销", revokeConfirm: "确定吊销该 API Key?", - scopesTitle: "权限范围", - scopesHint: "勾选后写入 Token;不勾选「继承」时至少选一项。默认 MCP 不含 ne:write,写工具对 Agent 不可见。", - scopesInherit: "继承所属用户全部权限", - scopesInheritShort: "继承用户", + editScopes: "改权限", + saveScopes: "保存权限", + scopesUnsetLegacy: "未限定(等同所属用户)", scopesCol: "权限", - scopesRequired: "请至少勾选一项权限,或选择继承用户全部权限", + scopesRequired: "请至少勾选一项权限", scopesNoneAvailable: "当前所属用户没有可授予的权限", - scopePresetMcp: "MCP 默认(只读+CLI)", - scopePresetTopoWrite: "MCP + 拓扑写", - scopeAlarmsRead: "alarms:read 告警只读", - scopeNeRead: "ne:read 网元/拓扑只读", - scopeNeWrite: "ne:write 网元/拓扑写入(含画图)", - scopeNeExec: "ne:exec 托管网元执行命令", - scopeWebcrt: "webcrt:session WebCRT", - scopeSql: "sql:query SQL 查询", - scopeAdminUsers: "admin:users 用户管理", - scopeOpsWrite: "ops:write 运维写入", + scopeAlarmsRead: "alarms:read — 告警只读(netx MCP)", + scopeNeRead: "ne:read — 网元/拓扑只读(MCP)", + scopeNeWrite: "ne:write — 网元/拓扑写入、画图(MCP)", + scopeNeExec: "ne:exec — 托管网元 CLI(netx MCP)", + scopeSql: "sql:query — UME SQL(netx MCP)", + scopeWebcrt: "webcrt:session — WebCRT(预留 MCP)", + scopeAdminUsers: "admin:users — 用户管理(预留 MCP)", + scopeOpsWrite: "ops:write — 运维写/订阅(预留 MCP)", forceChangeTitle: "请修改初始密码", forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。", oldPassword: "当前密码", diff --git a/web/src/index.css b/web/src/index.css index 05717a8..11be147 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1436,6 +1436,165 @@ pre { color: #475569; } +.token-page__title { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; +} + +.help-q { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + border: 1px solid #94a3b8; + color: #64748b; + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: help; + user-select: none; + flex: 0 0 auto; +} + +.help-q:hover, +.help-q:focus-visible { + border-color: #64748b; + color: #334155; + outline: none; +} + +.help-q__tip { + display: none; + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 90; + width: max-content; + max-width: min(360px, 70vw); + padding: 8px 10px; + border-radius: 6px; + background: #0f172a; + color: #f8fafc; + font-size: 12px; + font-weight: 400; + line-height: 1.45; + white-space: normal; + text-align: left; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.22); + pointer-events: none; +} + +.help-q__tip::after { + content: ""; + position: absolute; + bottom: 100%; + left: 8px; + border: 5px solid transparent; + border-bottom-color: #0f172a; +} + +.help-q:hover .help-q__tip, +.help-q:focus-visible .help-q__tip { + display: block; +} + +.token-scopes-modal { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.token-scopes-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(15, 27, 45, 0.45); +} + +.token-scopes-modal__panel { + position: relative; + z-index: 1; + width: min(480px, 100%); + max-height: min(80vh, 640px); + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 18px; + border-radius: 10px; + border: 1px solid #cdd6e2; + background: #fff; + box-shadow: 0 16px 40px rgba(15, 27, 45, 0.18); +} + +.token-scopes-modal__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.token-scopes-modal__head h3 { + margin: 0; + font-size: 16px; + font-weight: 650; + color: var(--nm-brand, #0f1b2d); +} + +.token-scopes-modal__list { + list-style: none; + margin: 0; + padding: 0; + overflow: auto; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.token-scopes-modal__list > li + li { + border-top: 1px solid #e2e8f0; +} + +.token-scopes-modal__row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + margin: 0; + cursor: pointer; + font-size: 13px; + color: #334155; +} + +.token-scopes-modal__row:hover { + background: #eef2f7; +} + +.token-scopes-modal__row input { + margin-top: 2px; + flex: 0 0 auto; +} + +.token-scopes-modal__label { + line-height: 1.4; +} + +.token-scopes-modal__foot { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + padding-top: 4px; +} + + .ume-page .pt-list-actions, .ume-page .pt-list-table .btn-row { gap: 6px; diff --git a/web/src/pages/ApiTokensPage.tsx b/web/src/pages/ApiTokensPage.tsx index 2d3b46b..22f58e2 100644 --- a/web/src/pages/ApiTokensPage.tsx +++ b/web/src/pages/ApiTokensPage.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { useMemo, useState, type FormEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "../auth/AuthContext"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; -import { apiDelete, apiGet, apiPost } from "../services/api"; +import { apiDelete, apiGet, apiPatch, apiPost } from "../services/api"; import { formatSystemTime } from "../utils/time"; type TokenRow = { @@ -33,22 +33,22 @@ const ALL_SCOPE_KEYS = [ "ne:read", "ne:write", "ne:exec", - "webcrt:session", "sql:query", + "webcrt:session", "admin:users", "ops:write", ] as const; -const MCP_DEFAULT_SCOPES = ["alarms:read", "ne:read", "ne:exec"] as const; -const MCP_TOPO_WRITE_SCOPES = ["alarms:read", "ne:read", "ne:exec", "ne:write"] as const; +/** Applied on create; refine later via「改权限」. */ +const CREATE_DEFAULT_SCOPES = ["alarms:read", "ne:read", "ne:exec", "ne:write"] as const; const SCOPE_LABEL_KEYS: Record<(typeof ALL_SCOPE_KEYS)[number], string> = { "alarms:read": "auth.scopeAlarmsRead", "ne:read": "auth.scopeNeRead", "ne:write": "auth.scopeNeWrite", "ne:exec": "auth.scopeNeExec", - "webcrt:session": "auth.scopeWebcrt", "sql:query": "auth.scopeSql", + "webcrt:session": "auth.scopeWebcrt", "admin:users": "auth.scopeAdminUsers", "ops:write": "auth.scopeOpsWrite", }; @@ -80,9 +80,9 @@ export function ApiTokensPage() { const [name, setName] = useState("mcp"); const [expiresInDays, setExpiresInDays] = useState(90); const [ownerUserId, setOwnerUserId] = useState(""); - const [inheritScopes, setInheritScopes] = useState(false); - const [selectedScopes, setSelectedScopes] = useState([...MCP_DEFAULT_SCOPES]); const [createdPlain, setCreatedPlain] = useState(""); + const [editingRow, setEditingRow] = useState(null); + const [editScopes, setEditScopes] = useState([]); const tokensQuery = useQuery({ queryKey: ["apiTokens"], @@ -99,7 +99,7 @@ export function ApiTokensPage() { const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]); const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]); - const availableScopes = useMemo(() => { + const availableForCreate = useMemo(() => { if (ownerUserId) { const owner = users.find((u) => u.id === ownerUserId); return [...(owner?.scopes || [])].sort(); @@ -107,22 +107,26 @@ export function ApiTokensPage() { return [...(myScopes || [])].sort(); }, [ownerUserId, users, myScopes]); - useEffect(() => { - setSelectedScopes((prev) => { - const next = prev.filter((s) => availableScopes.includes(s)); - if (next.length) return next; - return intersectScopes(availableScopes, MCP_DEFAULT_SCOPES); - }); - }, [availableScopes]); + const editAvailable = useMemo(() => { + if (!editingRow) return []; + if (editingRow.user_id === user?.id) return [...(myScopes || [])].sort(); + const owner = users.find((u) => u.id === editingRow.user_id); + return [...(owner?.scopes || myScopes || [])].sort(); + }, [editingRow, user?.id, users, myScopes]); const createMut = useMutation({ - mutationFn: () => - apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", { + mutationFn: () => { + const scopes = intersectScopes(availableForCreate, CREATE_DEFAULT_SCOPES); + if (!scopes.length) { + throw new Error(t("auth.scopesNoneAvailable")); + } + return apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", { name: name.trim() || "mcp", expires_in_days: expiresInDays, user_id: isAdmin && ownerUserId ? ownerUserId : undefined, - scopes: inheritScopes ? [] : selectedScopes, - }), + scopes, + }); + }, onSuccess: async (data) => { setCreatedPlain(data.token.token); showOk(t("auth.tokenCreated")); @@ -131,10 +135,24 @@ export function ApiTokensPage() { onError: (e) => showError(String(e instanceof Error ? e.message : e)), }); + const updateMut = useMutation({ + mutationFn: (payload: { id: string; scopes: string[] }) => + apiPatch<{ token: TokenRow }>(`/v1/api-tokens/${encodeURIComponent(payload.id)}`, { + scopes: payload.scopes, + }), + onSuccess: async () => { + showOk(t("auth.tokenUpdated")); + setEditingRow(null); + await qc.invalidateQueries({ queryKey: ["apiTokens"] }); + }, + onError: (e) => showError(String(e instanceof Error ? e.message : e)), + }); + const revokeMut = useMutation({ mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}`), onSuccess: async () => { showOk(t("auth.tokenRevoked")); + setEditingRow(null); await qc.invalidateQueries({ queryKey: ["apiTokens"] }); }, onError: (e) => showError(String(e instanceof Error ? e.message : e)), @@ -142,10 +160,6 @@ export function ApiTokensPage() { const onCreate = (e: FormEvent) => { e.preventDefault(); - if (!inheritScopes && selectedScopes.length === 0) { - showError(t("auth.scopesRequired")); - return; - } setCreatedPlain(""); createMut.mutate(); }; @@ -159,19 +173,24 @@ export function ApiTokensPage() { } }; - const toggleScope = (scope: string) => { - setSelectedScopes((prev) => + const toggleEditScope = (scope: string) => { + setEditScopes((prev) => prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope].sort(), ); }; - const applyPreset = (desired: readonly string[]) => { - setInheritScopes(false); - setSelectedScopes(intersectScopes(availableScopes, desired)); + const startEdit = (row: TokenRow) => { + const available = + row.user_id === user?.id + ? [...(myScopes || [])] + : [...(users.find((u) => u.id === row.user_id)?.scopes || myScopes || [])]; + const current = row.scopes?.length ? [...row.scopes] : [...available]; + setEditingRow(row); + setEditScopes(intersectScopes(available, current.length ? current : CREATE_DEFAULT_SCOPES)); }; const formatScopes = (scopes: string[] | undefined) => { - if (!scopes || scopes.length === 0) return t("auth.scopesInheritShort"); + if (!scopes || scopes.length === 0) return t("auth.scopesUnsetLegacy"); return scopes.join(", "); }; @@ -179,9 +198,16 @@ export function ApiTokensPage() {
-

{t("auth.apiKeysTitle")}

+

+ {t("auth.apiKeysTitle")} + + ? + + {t("auth.apiKeysHelp")} + + +

-

{t("auth.apiKeysHint")}

@@ -223,52 +249,6 @@ export function ApiTokensPage() { {t("auth.createToken")}
- -
-
- {t("auth.scopesTitle")} - - {t("auth.scopesHint")} - -
-
- - -
- -
- {ALL_SCOPE_KEYS.filter((s) => availableScopes.includes(s)).map((scope) => ( - - ))} - {!availableScopes.length ? ( - {t("auth.scopesNoneAvailable")} - ) : null} -
-
{createdPlain ? ( @@ -332,6 +312,14 @@ export function ApiTokensPage() {
+
+ + {editingRow ? ( +
+
setEditingRow(null)} /> +
+
+
+

{t("auth.editScopes")}

+

+ {editingRow.name} + {editingRow.username ? ` · ${editingRow.username}` : ""} +

+
+ +
+
    + {ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => ( +
  • + +
  • + ))} + {!editAvailable.length ? ( +
  • {t("auth.scopesNoneAvailable")}
  • + ) : null} +
+
+ + +
+
+
+ ) : null}
); } diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 2c6e14a..4217b5c 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -1,4 +1,13 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -358,7 +367,11 @@ function RouterIcon() { ); } -function NeNode({ data, selected }: NodeProps>) { +/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */ +const TOPO_NODE_W = 160; +const TOPO_NODE_H = 88; + +const NeNode = memo(function NeNode({ data, selected }: NodeProps>) { const { hideIp, hideVendor, connectMode } = useContext(TopoDisplayContext); const tone = nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id); const name = data.label || (!hideIp ? data.ne_ip : "") || "NE"; @@ -395,7 +408,7 @@ function NeNode({ data, selected }: NodeProps>) {
); -} +}); const nodeTypes = { neNode: NeNode }; @@ -550,6 +563,13 @@ function graphToFlow( id: n.fabric_node_id, type: "neNode", position: { x: n.x || 0, y: n.y || 0 }, + width: TOPO_NODE_W, + height: TOPO_NODE_H, + // Predetermined handles let onlyRenderVisibleElements skip measuring off-screen nodes. + handles: [ + { type: "target", position: Position.Left, x: 0, y: TOPO_NODE_H / 2 }, + { type: "source", position: Position.Right, x: TOPO_NODE_W, y: TOPO_NODE_H / 2 }, + ], data: { label: n.label || n.name || n.ip || n.fabric_node_id, managed_ne_id: n.managed_ne_id || "", @@ -3157,6 +3177,7 @@ export function TopologyPage() { )} edges={displayEdges} nodeTypes={nodeTypes} + onlyRenderVisibleElements connectionMode={ConnectionMode.Loose} defaultEdgeOptions={{ type: "straight" }} proOptions={{ hideAttribution: true }}