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:
oliver 2026-08-04 07:39:14 +08:00
parent bf0dfd66d8
commit 2d92dde8e5
18 changed files with 1294 additions and 257 deletions

View file

@ -1,9 +1,7 @@
--- ---
name: netx-topology name: netx-topology
description: >- description: >-
用 netx-topology MCP 查询 Fabric 链路、建拓扑画布并安全摆点(不污染 Fabric)。 用 netx-topology MCP 查链路、画拓扑(不污染 Fabric)。触发:画拓扑、LLDP、Fabric、netx-topology。先读再调。
触发:画拓扑/拓扑图/拓扑画布、LLDP 邻居/链路、Fabric 网元搜索、createTopologyView /
addTopologyViewNodes、netx-topology、看着 MCP 画图。须先读本 skill 再调 MCP。
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
--- ---
@ -16,47 +14,45 @@ disable-model-invocation: false
## 硬规则 ## 硬规则
1. **先读后写**:任何建图/摆点前先 `getTopologyTree`;改已有图前先 `getTopologyView`。 1. **先读后写**:任何建图/摆点前先 `getTopologyTree`;改已有图前可 `getTopologyView`(大图慎拉整图)。
2. **只放已有 Fabric 节点**:`addTopologyViewNodes` **仅** `fabric_node_ids`。禁止臆造 id;禁止试图传 `managed_ne_ids` / `ume_ne_ids`(会被拒)。 2. **筛选交给 API**:加/挪/删优先传 `keyword` / `role` / `vendor` / `link_status`(加节点再用 `limit`/`offset`)。**不要**先 list 全量再回传成千上万 id。
3. **不污染 Fabric**:本 MCP **不能**手工建链、不能 `populate`、不能删 Fabric / 整图。链路来自已有 LLDP/手工边;邻居用 `projectTopologyNeighbors`。 3. **只动已有 Fabric**:禁止 `managed_ne_ids` / `ume_ne_ids`;禁止臆造 fabric id。
4. **写权限**:画图工具要 token 含 `ne:write`。若 `tools/list` 没有写工具 → 停下来告诉用户去 **系统 → API Key** 用「MCP + 拓扑写」签发,并配置 `NETX_API_TOKEN` 后 Sync Tools。勿假装已画成功。 4. **不污染 Fabric**:不能手工建链、不能 populate、不能删 Fabric / 整图。邻居用 `projectTopologyNeighbors`。
5. **无文件夹则停**:`createTopologyView` 需要已有 `folder_id`(region)。树里没有可用 folder 时,请用户先在网页建区域,或改挂到已有 region;**MCP 不能新建 region/folder**。 5. **写权限**:需要 `ne:write`。tools/list 没有写工具 → 停,让用户用「MCP + 拓扑写」签发 Token。
6. **批量克制**:单次 `addTopologyViewNodes` 控制在合理数量(优先先搜再加);大图用多次调用 + `projectTopologyNeighbors` 扩展。 6. **无 folder 则停**:`createTopologyView` 需要已有 `folder_id`;MCP 不能新建 region。
7. **单画布硬顶 2000**:满了 `truncated` / 触顶 → 新建另一张画布继续;全网五万设备靠多画布切片。
## 推荐流水线(从零画一张图) ## 推荐流水线(从零画一张图)
``` ```
1 getTopologyTree → 选 folder_id(region) 1 getTopologyTree → folder_id
2 searchTopologyFabricNodes / listTopologyFabricNodes → 拿到 fabric_node_ids 2 createTopologyView → view_id
3 createTopologyView → name + folder_id → 得到 view_id 3 addTopologyViewNodes(keyword=…, limit) → 看 added / next_offset,循环 offset 直到无更多或满 cap
4 addTopologyViewNodes → view_id + fabric_node_ids(layout=grid) 4 projectTopologyNeighbors → 可选
5 projectTopologyNeighbors → 把已有 LLDP 邻居投影上画布(可重复) 5 updateTopologyViewPositions(layout=grid|offset|stack, keyword=…) → API 自己筛并摆
6 (可选)updateTopologyViewPositions → 微调坐标 6 (少量微调才用 positions[])
7 getTopologyView → 向用户确认节点/边数量
``` ```
查链路不画图时:`queryTopologyEdges`(带 `node_id` 看 `peer_count`)或 `queryTopologyNeighborhood`。 查链路不画图:`queryTopologyEdges` / `queryTopologyNeighborhood`。
## 工具速查 ## 工具速查
| 目的 | 工具 | | 目的 | 工具 |
|------|------| |------|------|
| 树 / region / 已有画布 | `getTopologyTree`, `listTopologyViews` | | 树 / 画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` |
| 读一图画布 | `getTopologyView` | | 筛选批量加 | `addTopologyViewNodes`(filters + limit/offset) |
| 新建画布 | `createTopologyView` | | 筛选批量挪 | `updateTopologyViewPositions`(layout + filters) |
| 摆点 / 移除(仅画布) | `addTopologyViewNodes`, `removeTopologyViewNodes` | | 筛选批量删 | `removeTopologyViewNodes`(filters) |
| 摆坐标 | `updateTopologyViewPositions` | | 投影邻居 | `projectTopologyNeighbors` |
| 投影 LLDP 邻居 | `projectTopologyNeighbors` | | Fabric 读 | `search…` / `list…` / `queryTopologyEdges` / `…Neighborhood` / summary |
| 搜 Fabric | `searchTopologyFabricNodes`, `listTopologyFabricNodes` |
| 汇总 / 邻接 / 边 | `getTopologyFabricSummary`, `queryTopologyNeighborhood`, `queryTopologyEdges` |
## 对人说清楚 ## 对人说清楚
- 网页观看:拓扑页左侧或浏览区开 **「实时同步」**(默认关);**不必先打开某张图**也能看到新建画布。 - 网页观看:拓扑页开 **「实时同步」**(默认关)。
- 回报时给出:`view_id`、画布名、folder、节点数;写失败则原样报 scope/API 错误。 - 回报:`view_id`、画布名、`added`/`updated`/`removed`、是否 `truncated`/`next_offset`。
## 不要做 ## 不要做
- 不要用 `netx`(告警 MCP)冒充拓扑写接口。 - 不要用 `netx` 告警 MCP 冒充拓扑写。
- 不要为「画上设备」去改 managed-NE / 造假 Fabric。 - 不要为画图去造 Fabric / 改 managed-NE。
- 不要在未确认 folder/view 时连环盲写。 - 不要在未确认 folder/view 时连环盲写。

View file

@ -81,16 +81,18 @@ oclaw:Install from JSON → Health → Sync Tools(应看到 **13** 个工具
| 工具 | 作用 | | 工具 | 作用 |
|------|------| |------|------|
| `createTopologyView` | 在 folder 下新建画布 | | `createTopologyView` | 在 folder 下新建画布 |
| `addTopologyViewNodes` | **仅** `fabric_node_ids` 放到画布(拒绝 managed/UME,避免创建 Fabric 占位) | | `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 |
| `removeTopologyViewNodes` | 从画布移除(不删 Fabric) | | `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 |
| `updateTopologyViewPositions` | 设置坐标 | | `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 |
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布 | | `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布 |
**刻意不提供:** 手工建链、`populate`(会经 managed 创建 Fabric 占位)、删 Fabric / 删整图。 **单画布硬顶 2000**;库存更大时多画布 + `offset` 翻页加满。前端对可见节点做 `onlyRenderVisibleElements`。
**刻意不提供:** 手工建链、`populate`、删 Fabric / 删整图。
写操作需要 token 具备 `ne:write`;只读为 `ne:read`。 写操作需要 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 新建区域/画布并往上加点。有未保存本地拖动时不会覆盖你的编辑。 **前端能否看着画:** 在拓扑页左侧树或右侧浏览区点 **「实时同步」**(默认关闭;**不需要先打开某张图**)。开启后树约每 5 秒、已打开的图约每 3 秒拉取,可看到 MCP 新建区域/画布并往上加点。有未保存本地拖动时不会覆盖你的编辑。

View file

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from .auth_deps import AuthContext, require_admin, require_user from .auth_deps import AuthContext, require_admin, require_user
from .auth_schemas import ( from .auth_schemas import (
ApiTokenCreateRequest, ApiTokenCreateRequest,
ApiTokenUpdateRequest,
ChangePasswordRequest, ChangePasswordRequest,
LoginRequest, LoginRequest,
UserCreateRequest, UserCreateRequest,
@ -25,6 +26,7 @@ from .auth_service import (
list_users, list_users,
login_issue_token, login_issue_token,
revoke_api_token, revoke_api_token,
update_api_token,
update_user, update_user,
user_public, user_public,
write_audit, 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}") @router.delete("/v1/api-tokens/{token_id}")
def api_revoke_token( def api_revoke_token(
token_id: str, token_id: str,

View file

@ -35,5 +35,11 @@ class ApiTokenCreateRequest(BaseModel):
expires_in_days: int | None = Field(default=90, ge=0, le=3650) 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. # Admin may create a token for another user; others ignored / forced to self.
user_id: str | None = None 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 scopes: list[str] | None = None

View file

@ -433,6 +433,47 @@ def revoke_api_token(db: Session, *, token_id: str, actor: AppUser) -> ApiToken:
return row 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: def resolve_api_token_row(db: Session, plaintext: str) -> ApiToken | None:
th = hash_api_token(plaintext) th = hash_api_token(plaintext)
row = ( row = (

View file

@ -38,6 +38,7 @@ from .topology_schemas import (
TopologyViewUpdate, TopologyViewUpdate,
ViewEdgeStylePatch, ViewEdgeStylePatch,
ViewNodesAdd, ViewNodesAdd,
ViewNodesRemove,
ViewPopulateRequest, ViewPopulateRequest,
ViewPositionsPatch, ViewPositionsPatch,
) )
@ -303,11 +304,10 @@ def api_populate_view(
@router.post("/views/{view_id}/nodes/remove") @router.post("/views/{view_id}/nodes/remove")
def api_remove_nodes( def api_remove_nodes(
view_id: str, view_id: str,
body: dict[str, Any], body: ViewNodesRemove,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> dict[str, Any]:
ids = body.get("fabric_node_ids") if isinstance(body, dict) else None return remove_view_nodes(db, view_id, body=body).model_dump()
return remove_view_nodes(db, view_id, list(ids or [])).model_dump()
@router.patch("/views/{view_id}/edge-style") @router.patch("/views/{view_id}/edge-style")

View file

@ -297,12 +297,48 @@ class ViewPopulateOut(BaseModel):
graph: TopologyViewGraphOut | None = None 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): class ViewPositionsPatch(BaseModel):
"""Move nodes: explicit positions and/or filter + layout (grid|offset|stack)."""
positions: list[ViewNodeIn] = Field(default_factory=list) 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): 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) managed_ne_ids: list[str] = Field(default_factory=list)
ume_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, default_factory=list,
description="Place existing fabric nodes onto the view", 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") 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): class ViewEdgeStylePatch(BaseModel):

View file

@ -61,9 +61,11 @@ from .topology_schemas import (
TopologyViewUpdate, TopologyViewUpdate,
ViewEdgeOut, ViewEdgeOut,
ViewEdgeStylePatch, ViewEdgeStylePatch,
ViewMutationOut,
ViewNodeIn, ViewNodeIn,
ViewNodeOut, ViewNodeOut,
ViewNodesAdd, ViewNodesAdd,
ViewNodesRemove,
ViewPopulateOut, ViewPopulateOut,
ViewPopulateRequest, ViewPopulateRequest,
ViewPositionsPatch, ViewPositionsPatch,
@ -308,57 +310,319 @@ def _place_fabric_ids_on_view(
return added 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( def patch_view_positions(
db: Session, view_id: str, body: ViewPositionsPatch db: Session, view_id: str, body: ViewPositionsPatch
) -> TopologyViewGraphOut: ) -> ViewMutationOut | TopologyViewGraphOut:
view = _get_view_or_404(db, view_id) view = _get_view_or_404(db, view_id)
mem = _membership_for_view(view)
max_nodes = int(mem.get("max_nodes") or 300)
now = _utcnow() now = _utcnow()
positions = list(body.positions or []) layout = str(body.layout or "").strip().lower()
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP: updated = 0
raise HTTPException(status_code=400, detail="too_many_positions") skipped_locked = 0
matched = 0
existing = { existing = {
vn.fabric_node_id: vn vn.fabric_node_id: vn
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() 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 layout in {"grid", "offset", "stack"}:
if not fid: ids = select_view_fabric_ids(
continue db,
if db.get(TopoFabricNode, fid) is None: view.id,
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}") fabric_node_ids=list(body.fabric_node_ids or []),
row = existing.get(fid) keyword=body.keyword,
if row is None: role=body.role,
row = TopoViewNode( vendor=body.vendor,
id=uuid4().hex, link_status=body.link_status,
view_id=view.id, )
fabric_node_id=fid, if not ids and not _has_fabric_filter(
x=float(p.x or 0), keyword=body.keyword,
y=float(p.y or 0), role=body.role,
label=str(p.label or "")[:256], vendor=body.vendor,
locked=bool(p.locked), link_status=body.link_status,
created_at=now, ) and not (body.fabric_node_ids or []):
updated_at=now, # layout with no filter/ids → all nodes on view
) ids = sorted(existing.keys())
db.add(row) matched = len(ids)
existing[fid] = row 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: else:
if row.locked and not p.locked: coords = _layout_coords(
# allow unlock + move when explicitly unlocked in patch len(ids),
pass layout=layout,
if row.locked and bool(p.locked): 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 continue
row.x = float(p.x or 0) if db.get(TopoFabricNode, fid) is None:
row.y = float(p.y or 0) raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
if p.label is not None: row = existing.get(fid)
row.label = str(p.label or "")[:256] if row is None:
row.locked = bool(p.locked) row = TopoViewNode(
row.updated_at = now 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 view.updated_at = now
db.commit() 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) view = _get_view_or_404(db, view_id)
mem = _membership_for_view(view) mem = _membership_for_view(view)
max_nodes = int(mem.get("max_nodes") or 300) 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 vn.fabric_node_id
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
} }
if len(existing) >= max_nodes: original_count = len(existing)
raise HTTPException(status_code=400, detail="membership_max_nodes") if original_count >= max_nodes:
added_ids: list[str] = [] 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 []: for mid in body.managed_ne_ids or []:
mid_s = str(mid or "").strip() mid_s = str(mid or "").strip()
if not mid_s: 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: if ne is None:
continue continue
fn = ensure_fabric_node_for_managed(db, ne) fn = ensure_fabric_node_for_managed(db, ne)
if fn.id not in existing: candidate_ids.append(fn.id)
added_ids.append(fn.id) matched_total += 1
existing.add(fn.id)
default_profile = get_default_profile(db) default_profile = get_default_profile(db)
for uid in body.ume_ne_ids or []: for uid in body.ume_ne_ids or []:
uid_s = str(uid or "").strip() uid_s = str(uid or "").strip()
@ -394,26 +699,56 @@ def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> Topology
else: else:
dtype, vendor = "zte_zxros", (ume.vendor or "ZTE") dtype, vendor = "zte_zxros", (ume.vendor or "ZTE")
fn = ensure_fabric_node_for_ume(db, ume, device_type=dtype, vendor=vendor) fn = ensure_fabric_node_for_ume(db, ume, device_type=dtype, vendor=vendor)
if fn.id not in existing: candidate_ids.append(fn.id)
added_ids.append(fn.id) matched_total += 1
existing.add(fn.id)
for fid in body.fabric_node_ids or []: # Dedupe preserve order
fid_s = str(fid or "").strip() seen: set[str] = set()
if not fid_s or fid_s in existing: ordered: list[str] = []
for fid in candidate_ids:
if fid in seen:
continue 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 continue
added_ids.append(fid_s) if db.get(TopoFabricNode, fid) is None:
existing.add(fid_s) skipped_missing += 1
# `existing` already includes ids in added_ids; cap new placements. continue
original_count = len(existing) - len(added_ids) to_add.append(fid)
room = max(0, max_nodes - original_count) room = max(0, max_nodes - original_count)
if len(added_ids) > room: truncated = len(to_add) > room
added_ids = added_ids[:room] if truncated:
cols = max(1, int(len(added_ids) ** 0.5) or 1) to_add = to_add[:room]
for i, fid in enumerate(added_ids): next_offset = None # capped by membership; caller should open another view
x = (i % cols) * 180.0 + 40.0
y = (i // cols) * 120.0 + 40.0 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( db.add(
TopoViewNode( TopoViewNode(
id=uuid4().hex, id=uuid4().hex,
@ -427,9 +762,26 @@ def add_nodes_to_view(db: Session, view_id: str, body: ViewNodesAdd) -> Topology
updated_at=now, updated_at=now,
) )
) )
view.updated_at = now existing.add(fid)
db.commit()
return get_view_graph(db, view.id) 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( 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) 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: if ids:
db.query(TopoViewNode).filter( removed = (
TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id.in_(ids) db.query(TopoViewNode)
).delete(synchronize_session=False) .filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id.in_(ids))
.delete(synchronize_session=False)
)
view.updated_at = _utcnow() view.updated_at = _utcnow()
db.commit() 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})$") _HEX_COLOR_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")

View file

@ -33,7 +33,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
| 类别 | 工具 | | 类别 | 工具 |
|------|------| |------|------|
| 树/画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` | | 树/画布 | `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` | | Fabric 只读 | `getTopologyFabricSummary`, `listTopologyFabricNodes`, `searchTopologyFabricNodes`, `queryTopologyNeighborhood`, `queryTopologyEdges` |
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。 **安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。

View file

@ -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)) 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]: 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() view_id = str(args.get("view_id") or "").strip()
if not view_id: if not view_id:
return {"ok": False, "error": "view_id_required"} 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"): if args.get("managed_ne_ids") or args.get("ume_ne_ids"):
return { return {
"ok": False, "ok": False,
"error": "fabric_nodes_only", "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()] fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not fabric_ids: if not filters and not fabric_ids:
return {"ok": False, "error": "fabric_node_ids_required"} return {
body: dict[str, Any] = { "ok": False,
"managed_ne_ids": [], "error": "filter_or_fabric_node_ids_required",
"ume_ne_ids": [], "detail": "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.",
"fabric_node_ids": fabric_ids, }
"layout": str(args.get("layout") or "grid").strip() or "grid",
} 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 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]: def _remove_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
view_id = str(args.get("view_id") or "").strip() 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: if not view_id:
return {"ok": False, "error": "view_id_required"} return {"ok": False, "error": "view_id_required"}
if not ids: filters = _filter_fields(args)
return {"ok": False, "error": "fabric_node_ids_required"} ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
return _data( if not filters and not ids:
http_json("POST", f"/v1/topology/views/{view_id}/nodes/remove", body={"fabric_node_ids": 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]: def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
view_id = str(args.get("view_id") or "").strip() view_id = str(args.get("view_id") or "").strip()
positions = args.get("positions")
if not view_id: if not view_id:
return {"ok": False, "error": "view_id_required"} 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: 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]] = [] cleaned: list[dict[str, Any]] = []
for p in positions: for p in positions:
if not isinstance(p, dict): if not isinstance(p, dict):
@ -118,7 +221,19 @@ def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
) )
if not cleaned: if not cleaned:
return {"ok": False, "error": "positions_required"} 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]: def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
@ -267,40 +382,79 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
{ {
"name": "addTopologyViewNodes", "name": "addTopologyViewNodes",
"description": ( "description": (
"Place existing fabric nodes onto a view canvas (layout=grid|keep). " "Bulk-place existing fabric nodes on a view. Prefer server filters "
"Only fabric_node_ids — never creates fabric placeholders from managed/UME ids." "(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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"view_id": {"type": "string"}, "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"}, "layout": {"type": "string", "enum": ["grid", "keep"], "default": "grid"},
}, },
"required": ["view_id", "fabric_node_ids"], "required": ["view_id"],
"additionalProperties": False, "additionalProperties": False,
}, },
}, },
{ {
"name": "removeTopologyViewNodes", "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"view_id": {"type": "string"}, "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, "additionalProperties": False,
}, },
}, },
{ {
"name": "updateTopologyViewPositions", "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"view_id": {"type": "string"}, "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": { "positions": {
"type": "array", "type": "array",
"items": { "items": {
@ -315,10 +469,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"required": ["fabric_node_id"], "required": ["fabric_node_id"],
"additionalProperties": False, "additionalProperties": False,
}, },
"minItems": 1,
}, },
}, },
"required": ["view_id", "positions"], "required": ["view_id"],
"additionalProperties": False, "additionalProperties": False,
}, },
}, },

View file

@ -35,7 +35,7 @@ def test_add_nodes_rejects_managed_ume_ids() -> None:
def test_add_nodes_posts_fabric_ids_only() -> None: def test_add_nodes_posts_fabric_ids_only() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http: 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( out = call_http_tool(
"addTopologyViewNodes", "addTopologyViewNodes",
{"view_id": "v1", "fabric_node_ids": ["f1", "f2"], "layout": "grid"}, {"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["fabric_node_ids"] == ["f1", "f2"]
assert body["managed_ne_ids"] == [] assert body["managed_ne_ids"] == []
assert body["ume_ne_ids"] == [] assert body["ume_ne_ids"] == []
assert body["return_graph"] is False
payload = json.loads(out["content"][0]["text"]) payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True 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: def test_create_view_requires_folder() -> None:
out = call_http_tool("createTopologyView", {"name": "map1"}) out = call_http_tool("createTopologyView", {"name": "map1"})
assert out.get("isError") is True assert out.get("isError") is True

View file

@ -257,6 +257,30 @@ class AuthApiTests(unittest.TestCase):
# Token cannot escalate beyond listed scopes (admin owner still capped by token list). # Token cannot escalate beyond listed scopes (admin owner still capped by token list).
self.assertNotIn("admin:users", granted) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -1526,6 +1526,74 @@ Management Addresses:
self.assertIsNotNone(self.db.get(ManagedNE, real.id)) self.assertIsNotNone(self.db.get(ManagedNE, real.id))
self.assertIsNotNone(self.db.get(ManagedNE, ph.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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -11,6 +11,7 @@ const en = {
pagerMeta: "{{total}} items · page {{page}}/{{pages}}", pagerMeta: "{{total}} items · page {{page}}/{{pages}}",
opFailed: "Operation failed", opFailed: "Operation failed",
empty: "-", empty: "-",
cancel: "Cancel",
}, },
workbench: { workbench: {
title: "Workbench", title: "Workbench",
@ -476,8 +477,8 @@ const en = {
colStatus: "Status", colStatus: "Status",
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key management", apiKeysTitle: "API Key management",
apiKeysHint: apiKeysHelp:
"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.", "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", tokenName: "Name",
expiresIn: "Expiry", expiresIn: "Expiry",
expire7d: "7 days", expire7d: "7 days",
@ -489,6 +490,7 @@ const en = {
tokenOwnerSelf: "Myself ({{user}})", tokenOwnerSelf: "Myself ({{user}})",
createToken: "Create key", createToken: "Create key",
tokenCreated: "API key created", tokenCreated: "API key created",
tokenUpdated: "Scopes updated",
tokenRevoked: "Revoked", tokenRevoked: "Revoked",
tokenOnceHint: "Copy and store this secret now; it will not be shown again:", tokenOnceHint: "Copy and store this secret now; it will not be shown again:",
copyToken: "Copy", copyToken: "Copy",
@ -501,24 +503,21 @@ const en = {
tokenStatusRevoked: "Revoked", tokenStatusRevoked: "Revoked",
revokeToken: "Revoke", revokeToken: "Revoke",
revokeConfirm: "Revoke this API key?", revokeConfirm: "Revoke this API key?",
scopesTitle: "Scopes", editScopes: "Edit scopes",
scopesHint: saveScopes: "Save scopes",
"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.", scopesUnsetLegacy: "Unrestricted (same as owner)",
scopesInherit: "Inherit all owner scopes",
scopesInheritShort: "Inherit owner",
scopesCol: "Scopes", 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", scopesNoneAvailable: "This owner has no grantable scopes",
scopePresetMcp: "MCP default (read + CLI)", scopeAlarmsRead: "alarms:read — Alarms read (netx MCP)",
scopePresetTopoWrite: "MCP + topology write", scopeNeRead: "ne:read — NE / topology read (MCP)",
scopeAlarmsRead: "alarms:read Alarms read", scopeNeWrite: "ne:write — NE / topology write / draw (MCP)",
scopeNeRead: "ne:read NE / topology read", scopeNeExec: "ne:exec — Managed NE CLI (netx MCP)",
scopeNeWrite: "ne:write NE / topology write (draw)", scopeSql: "sql:query — UME SQL (netx MCP)",
scopeNeExec: "ne:exec Managed NE exec", scopeWebcrt: "webcrt:session — WebCRT (reserved MCP)",
scopeWebcrt: "webcrt:session WebCRT", scopeAdminUsers: "admin:users — User admin (reserved MCP)",
scopeSql: "sql:query SQL", scopeOpsWrite: "ops:write — Ops write / subscriptions (reserved MCP)",
scopeAdminUsers: "admin:users User admin",
scopeOpsWrite: "ops:write Ops write",
forceChangeTitle: "Change initial password", forceChangeTitle: "Change initial password",
forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.", forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.",
oldPassword: "Current password", oldPassword: "Current password",

View file

@ -11,6 +11,7 @@ const zh = {
pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页", pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页",
opFailed: "操作失败", opFailed: "操作失败",
empty: "-", empty: "-",
cancel: "取消",
}, },
workbench: { workbench: {
title: "工作台", title: "工作台",
@ -473,7 +474,8 @@ const zh = {
colStatus: "状态码", colStatus: "状态码",
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key 管理", apiKeysTitle: "API Key 管理",
apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。可勾选权限;拓扑画图需 ne:write。", apiKeysHelp:
"网页登录不用 API Key,Key 给 MCP/脚本用。生成时默认 alarms:read + ne:read + ne:write + ne:exec,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接 MCP 的项为预留。",
tokenName: "名称", tokenName: "名称",
expiresIn: "有效期", expiresIn: "有效期",
expire7d: "7 天", expire7d: "7 天",
@ -485,6 +487,7 @@ const zh = {
tokenOwnerSelf: "自己({{user}})", tokenOwnerSelf: "自己({{user}})",
createToken: "生成 Key", createToken: "生成 Key",
tokenCreated: "API Key 已生成", tokenCreated: "API Key 已生成",
tokenUpdated: "权限已更新",
tokenRevoked: "已吊销", tokenRevoked: "已吊销",
tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:", tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:",
copyToken: "复制", copyToken: "复制",
@ -497,23 +500,20 @@ const zh = {
tokenStatusRevoked: "已吊销", tokenStatusRevoked: "已吊销",
revokeToken: "吊销", revokeToken: "吊销",
revokeConfirm: "确定吊销该 API Key?", revokeConfirm: "确定吊销该 API Key?",
scopesTitle: "权限范围", editScopes: "改权限",
scopesHint: "勾选后写入 Token;不勾选「继承」时至少选一项。默认 MCP 不含 ne:write,写工具对 Agent 不可见。", saveScopes: "保存权限",
scopesInherit: "继承所属用户全部权限", scopesUnsetLegacy: "未限定(等同所属用户)",
scopesInheritShort: "继承用户",
scopesCol: "权限", scopesCol: "权限",
scopesRequired: "请至少勾选一项权限,或选择继承用户全部权限", scopesRequired: "请至少勾选一项权限",
scopesNoneAvailable: "当前所属用户没有可授予的权限", scopesNoneAvailable: "当前所属用户没有可授予的权限",
scopePresetMcp: "MCP 默认(只读+CLI)", scopeAlarmsRead: "alarms:read — 告警只读(netx MCP)",
scopePresetTopoWrite: "MCP + 拓扑写", scopeNeRead: "ne:read — 网元/拓扑只读(MCP)",
scopeAlarmsRead: "alarms:read 告警只读", scopeNeWrite: "ne:write — 网元/拓扑写入、画图(MCP)",
scopeNeRead: "ne:read 网元/拓扑只读", scopeNeExec: "ne:exec — 托管网元 CLI(netx MCP)",
scopeNeWrite: "ne:write 网元/拓扑写入(含画图)", scopeSql: "sql:query — UME SQL(netx MCP)",
scopeNeExec: "ne:exec 托管网元执行命令", scopeWebcrt: "webcrt:session — WebCRT(预留 MCP)",
scopeWebcrt: "webcrt:session WebCRT", scopeAdminUsers: "admin:users — 用户管理(预留 MCP)",
scopeSql: "sql:query SQL 查询", scopeOpsWrite: "ops:write — 运维写/订阅(预留 MCP)",
scopeAdminUsers: "admin:users 用户管理",
scopeOpsWrite: "ops:write 运维写入",
forceChangeTitle: "请修改初始密码", forceChangeTitle: "请修改初始密码",
forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。", forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。",
oldPassword: "当前密码", oldPassword: "当前密码",

View file

@ -1436,6 +1436,165 @@ pre {
color: #475569; 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-actions,
.ume-page .pt-list-table .btn-row { .ume-page .pt-list-table .btn-row {
gap: 6px; gap: 6px;

View file

@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast"; 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"; import { formatSystemTime } from "../utils/time";
type TokenRow = { type TokenRow = {
@ -33,22 +33,22 @@ const ALL_SCOPE_KEYS = [
"ne:read", "ne:read",
"ne:write", "ne:write",
"ne:exec", "ne:exec",
"webcrt:session",
"sql:query", "sql:query",
"webcrt:session",
"admin:users", "admin:users",
"ops:write", "ops:write",
] as const; ] as const;
const MCP_DEFAULT_SCOPES = ["alarms:read", "ne:read", "ne:exec"] as const; /** Applied on create; refine later via「改权限」. */
const MCP_TOPO_WRITE_SCOPES = ["alarms:read", "ne:read", "ne:exec", "ne:write"] as const; 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> = { const SCOPE_LABEL_KEYS: Record<(typeof ALL_SCOPE_KEYS)[number], string> = {
"alarms:read": "auth.scopeAlarmsRead", "alarms:read": "auth.scopeAlarmsRead",
"ne:read": "auth.scopeNeRead", "ne:read": "auth.scopeNeRead",
"ne:write": "auth.scopeNeWrite", "ne:write": "auth.scopeNeWrite",
"ne:exec": "auth.scopeNeExec", "ne:exec": "auth.scopeNeExec",
"webcrt:session": "auth.scopeWebcrt",
"sql:query": "auth.scopeSql", "sql:query": "auth.scopeSql",
"webcrt:session": "auth.scopeWebcrt",
"admin:users": "auth.scopeAdminUsers", "admin:users": "auth.scopeAdminUsers",
"ops:write": "auth.scopeOpsWrite", "ops:write": "auth.scopeOpsWrite",
}; };
@ -80,9 +80,9 @@ export function ApiTokensPage() {
const [name, setName] = useState("mcp"); const [name, setName] = useState("mcp");
const [expiresInDays, setExpiresInDays] = useState(90); const [expiresInDays, setExpiresInDays] = useState(90);
const [ownerUserId, setOwnerUserId] = useState(""); const [ownerUserId, setOwnerUserId] = useState("");
const [inheritScopes, setInheritScopes] = useState(false);
const [selectedScopes, setSelectedScopes] = useState<string[]>([...MCP_DEFAULT_SCOPES]);
const [createdPlain, setCreatedPlain] = useState(""); const [createdPlain, setCreatedPlain] = useState("");
const [editingRow, setEditingRow] = useState<TokenRow | null>(null);
const [editScopes, setEditScopes] = useState<string[]>([]);
const tokensQuery = useQuery({ const tokensQuery = useQuery({
queryKey: ["apiTokens"], queryKey: ["apiTokens"],
@ -99,7 +99,7 @@ export function ApiTokensPage() {
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]); const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]); const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
const availableScopes = useMemo(() => { const availableForCreate = useMemo(() => {
if (ownerUserId) { if (ownerUserId) {
const owner = users.find((u) => u.id === ownerUserId); const owner = users.find((u) => u.id === ownerUserId);
return [...(owner?.scopes || [])].sort(); return [...(owner?.scopes || [])].sort();
@ -107,22 +107,26 @@ export function ApiTokensPage() {
return [...(myScopes || [])].sort(); return [...(myScopes || [])].sort();
}, [ownerUserId, users, myScopes]); }, [ownerUserId, users, myScopes]);
useEffect(() => { const editAvailable = useMemo(() => {
setSelectedScopes((prev) => { if (!editingRow) return [];
const next = prev.filter((s) => availableScopes.includes(s)); if (editingRow.user_id === user?.id) return [...(myScopes || [])].sort();
if (next.length) return next; const owner = users.find((u) => u.id === editingRow.user_id);
return intersectScopes(availableScopes, MCP_DEFAULT_SCOPES); return [...(owner?.scopes || myScopes || [])].sort();
}); }, [editingRow, user?.id, users, myScopes]);
}, [availableScopes]);
const createMut = useMutation({ const createMut = useMutation({
mutationFn: () => mutationFn: () => {
apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", { 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", name: name.trim() || "mcp",
expires_in_days: expiresInDays, expires_in_days: expiresInDays,
user_id: isAdmin && ownerUserId ? ownerUserId : undefined, user_id: isAdmin && ownerUserId ? ownerUserId : undefined,
scopes: inheritScopes ? [] : selectedScopes, scopes,
}), });
},
onSuccess: async (data) => { onSuccess: async (data) => {
setCreatedPlain(data.token.token); setCreatedPlain(data.token.token);
showOk(t("auth.tokenCreated")); showOk(t("auth.tokenCreated"));
@ -131,10 +135,24 @@ export function ApiTokensPage() {
onError: (e) => showError(String(e instanceof Error ? e.message : e)), 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({ const revokeMut = useMutation({
mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}`), mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}`),
onSuccess: async () => { onSuccess: async () => {
showOk(t("auth.tokenRevoked")); showOk(t("auth.tokenRevoked"));
setEditingRow(null);
await qc.invalidateQueries({ queryKey: ["apiTokens"] }); await qc.invalidateQueries({ queryKey: ["apiTokens"] });
}, },
onError: (e) => showError(String(e instanceof Error ? e.message : e)), onError: (e) => showError(String(e instanceof Error ? e.message : e)),
@ -142,10 +160,6 @@ export function ApiTokensPage() {
const onCreate = (e: FormEvent) => { const onCreate = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!inheritScopes && selectedScopes.length === 0) {
showError(t("auth.scopesRequired"));
return;
}
setCreatedPlain(""); setCreatedPlain("");
createMut.mutate(); createMut.mutate();
}; };
@ -159,19 +173,24 @@ export function ApiTokensPage() {
} }
}; };
const toggleScope = (scope: string) => { const toggleEditScope = (scope: string) => {
setSelectedScopes((prev) => setEditScopes((prev) =>
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope].sort(), prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope].sort(),
); );
}; };
const applyPreset = (desired: readonly string[]) => { const startEdit = (row: TokenRow) => {
setInheritScopes(false); const available =
setSelectedScopes(intersectScopes(availableScopes, desired)); 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) => { 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(", "); return scopes.join(", ");
}; };
@ -179,9 +198,16 @@ export function ApiTokensPage() {
<div className="page-stack system-page"> <div className="page-stack system-page">
<section className="panel"> <section className="panel">
<div className="panel__toolbar"> <div className="panel__toolbar">
<h2>{t("auth.apiKeysTitle")}</h2> <h2 className="token-page__title">
{t("auth.apiKeysTitle")}
<span className="help-q" tabIndex={0} aria-label={t("auth.apiKeysHelp")}>
?
<span className="help-q__tip" role="tooltip">
{t("auth.apiKeysHelp")}
</span>
</span>
</h2>
</div> </div>
<p className="panel__hint">{t("auth.apiKeysHint")}</p>
<div className="pt-list"> <div className="pt-list">
<form className="token-create" onSubmit={onCreate}> <form className="token-create" onSubmit={onCreate}>
@ -223,52 +249,6 @@ export function ApiTokensPage() {
{t("auth.createToken")} {t("auth.createToken")}
</button> </button>
</div> </div>
<div className="token-scopes">
<div className="token-scopes__head">
<strong>{t("auth.scopesTitle")}</strong>
<span className="panel__hint" style={{ margin: 0 }}>
{t("auth.scopesHint")}
</span>
</div>
<div className="token-scopes__presets">
<button type="button" className="btn--ghost btn--sm" onClick={() => applyPreset(MCP_DEFAULT_SCOPES)}>
{t("auth.scopePresetMcp")}
</button>
<button
type="button"
className="btn--ghost btn--sm"
onClick={() => applyPreset(MCP_TOPO_WRITE_SCOPES)}
disabled={!availableScopes.includes("ne:write")}
>
{t("auth.scopePresetTopoWrite")}
</button>
</div>
<label className="token-scopes__inherit">
<input
type="checkbox"
checked={inheritScopes}
onChange={(e) => setInheritScopes(e.target.checked)}
/>
{t("auth.scopesInherit")}
</label>
<div className={`token-scopes__grid${inheritScopes ? " is-disabled" : ""}`}>
{ALL_SCOPE_KEYS.filter((s) => availableScopes.includes(s)).map((scope) => (
<label key={scope}>
<input
type="checkbox"
disabled={inheritScopes}
checked={selectedScopes.includes(scope)}
onChange={() => toggleScope(scope)}
/>
{t(SCOPE_LABEL_KEYS[scope])}
</label>
))}
{!availableScopes.length ? (
<span className="muted">{t("auth.scopesNoneAvailable")}</span>
) : null}
</div>
</div>
</form> </form>
{createdPlain ? ( {createdPlain ? (
@ -332,6 +312,14 @@ export function ApiTokensPage() {
</td> </td>
<td> <td>
<div className="btn-row pt-list-actions table-actions"> <div className="btn-row pt-list-actions table-actions">
<button
type="button"
className="btn--ghost"
disabled={row.revoked || row.expired}
onClick={() => startEdit(row)}
>
{t("auth.editScopes")}
</button>
<button <button
type="button" type="button"
className="btn--danger" className="btn--danger"
@ -352,6 +340,60 @@ export function ApiTokensPage() {
)} )}
</div> </div>
</section> </section>
{editingRow ? (
<div
className="token-scopes-modal"
role="dialog"
aria-modal="true"
aria-label={t("auth.editScopes")}
>
<div className="token-scopes-modal__backdrop" onClick={() => setEditingRow(null)} />
<div className="token-scopes-modal__panel">
<div className="token-scopes-modal__head">
<div>
<h3>{t("auth.editScopes")}</h3>
<p className="panel__hint" style={{ margin: "4px 0 0" }}>
{editingRow.name}
{editingRow.username ? ` · ${editingRow.username}` : ""}
</p>
</div>
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}>
{t("common.cancel")}
</button>
</div>
<ul className="token-scopes-modal__list">
{ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => (
<li key={scope}>
<label className="token-scopes-modal__row">
<input
type="checkbox"
checked={editScopes.includes(scope)}
onChange={() => toggleEditScope(scope)}
/>
<span className="token-scopes-modal__label">{t(SCOPE_LABEL_KEYS[scope])}</span>
</label>
</li>
))}
{!editAvailable.length ? (
<li className="muted">{t("auth.scopesNoneAvailable")}</li>
) : null}
</ul>
<div className="token-scopes-modal__foot">
<button
type="button"
disabled={updateMut.isPending || editScopes.length === 0}
onClick={() => updateMut.mutate({ id: editingRow.id, scopes: editScopes })}
>
{t("auth.saveScopes")}
</button>
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}>
{t("common.cancel")}
</button>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }

View file

@ -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 { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -358,7 +367,11 @@ function RouterIcon() {
); );
} }
function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) { /** 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<Node<NeNodeData>>) {
const { hideIp, hideVendor, connectMode } = useContext(TopoDisplayContext); const { hideIp, hideVendor, connectMode } = useContext(TopoDisplayContext);
const tone = nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id); const tone = nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
const name = data.label || (!hideIp ? data.ne_ip : "") || "NE"; const name = data.label || (!hideIp ? data.ne_ip : "") || "NE";
@ -395,7 +408,7 @@ function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
</div> </div>
</div> </div>
); );
} });
const nodeTypes = { neNode: NeNode }; const nodeTypes = { neNode: NeNode };
@ -550,6 +563,13 @@ function graphToFlow(
id: n.fabric_node_id, id: n.fabric_node_id,
type: "neNode", type: "neNode",
position: { x: n.x || 0, y: n.y || 0 }, 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: { data: {
label: n.label || n.name || n.ip || n.fabric_node_id, label: n.label || n.name || n.ip || n.fabric_node_id,
managed_ne_id: n.managed_ne_id || "", managed_ne_id: n.managed_ne_id || "",
@ -3157,6 +3177,7 @@ export function TopologyPage() {
)} )}
edges={displayEdges} edges={displayEdges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
onlyRenderVisibleElements
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
defaultEdgeOptions={{ type: "straight" }} defaultEdgeOptions={{ type: "straight" }}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}