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