mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 21:03:18 +08:00
Add standalone topology MCP with skill, scopes UI, and opt-in live sync.
Splits canvas/Fabric tools into netx-topology-mcp, documents install and a companion Cursor skill, lets API keys grant ne:write explicitly, and adds optional topology live sync so operators can watch agent drawing without constant polling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a95b96f648
commit
bf0dfd66d8
30 changed files with 2057 additions and 410 deletions
62
.cursor/skills/netx-topology/SKILL.md
Normal file
62
.cursor/skills/netx-topology/SKILL.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
name: netx-topology
|
||||
description: >-
|
||||
用 netx-topology MCP 查询 Fabric 链路、建拓扑画布并安全摆点(不污染 Fabric)。
|
||||
触发:画拓扑/拓扑图/拓扑画布、LLDP 邻居/链路、Fabric 网元搜索、createTopologyView /
|
||||
addTopologyViewNodes、netx-topology、看着 MCP 画图。须先读本 skill 再调 MCP。
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# netx 拓扑 MCP
|
||||
|
||||
通过 **`netx-topology`** MCP(包 `netx-topology-mcp`)操作 netx 拓扑。与告警/CLI 的 **`netx`** MCP **分开**;画图只用本包工具。
|
||||
|
||||
安装与 scopes 真源:仓库 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md)。
|
||||
|
||||
## 硬规则
|
||||
|
||||
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 → 选 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 → 向用户确认节点/边数量
|
||||
```
|
||||
|
||||
查链路不画图时:`queryTopologyEdges`(带 `node_id` 看 `peer_count`)或 `queryTopologyNeighborhood`。
|
||||
|
||||
## 工具速查
|
||||
|
||||
| 目的 | 工具 |
|
||||
|------|------|
|
||||
| 树 / region / 已有画布 | `getTopologyTree`, `listTopologyViews` |
|
||||
| 读一图画布 | `getTopologyView` |
|
||||
| 新建画布 | `createTopologyView` |
|
||||
| 摆点 / 移除(仅画布) | `addTopologyViewNodes`, `removeTopologyViewNodes` |
|
||||
| 摆坐标 | `updateTopologyViewPositions` |
|
||||
| 投影 LLDP 邻居 | `projectTopologyNeighbors` |
|
||||
| 搜 Fabric | `searchTopologyFabricNodes`, `listTopologyFabricNodes` |
|
||||
| 汇总 / 邻接 / 边 | `getTopologyFabricSummary`, `queryTopologyNeighborhood`, `queryTopologyEdges` |
|
||||
|
||||
## 对人说清楚
|
||||
|
||||
- 网页观看:拓扑页左侧或浏览区开 **「实时同步」**(默认关);**不必先打开某张图**也能看到新建画布。
|
||||
- 回报时给出:`view_id`、画布名、folder、节点数;写失败则原样报 scope/API 错误。
|
||||
|
||||
## 不要做
|
||||
|
||||
- 不要用 `netx`(告警 MCP)冒充拓扑写接口。
|
||||
- 不要为「画上设备」去改 managed-NE / 造假 Fabric。
|
||||
- 不要在未确认 folder/view 时连环盲写。
|
||||
12
README.md
12
README.md
|
|
@ -160,19 +160,23 @@ API base: `http://127.0.0.1:8890/`
|
|||
|
||||
先启动 netx API(§5),再在 **MCP 宿主同机** 安装轻量客户端并配置。
|
||||
|
||||
**完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.md)**
|
||||
**完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.md)**
|
||||
**拓扑画布独立 MCP** 见:[docs/MCP_TOPOLOGY.md](docs/MCP_TOPOLOGY.md)
|
||||
|
||||
速查:
|
||||
|
||||
```powershell
|
||||
pip install -e ./packages/netx-mcp
|
||||
# 拓扑(可选,单独安装)
|
||||
pip install -e ./packages/netx-topology-mcp
|
||||
# 配置见 mcp.json,运行:
|
||||
python -m netx_mcp
|
||||
python -m netx_topology_mcp
|
||||
```
|
||||
|
||||
- 客户端配置:[`mcp.json`](mcp.json)(Cursor / oclaw Admin 粘贴同一份)
|
||||
- oclaw 可选 payload:[`mcp_install_payload.json`](mcp_install_payload.json)
|
||||
- 子包说明:[`packages/netx-mcp/README.md`](packages/netx-mcp/README.md)
|
||||
- 客户端配置:[`mcp.json`](mcp.json)(含 `netx` + 可选 `netx-topology`)
|
||||
- oclaw payload:[`mcp_install_payload.json`](mcp_install_payload.json) / [`mcp_topology_install_payload.json`](mcp_topology_install_payload.json)
|
||||
- 子包:[`packages/netx-mcp`](packages/netx-mcp/README.md)、[`packages/netx-topology-mcp`](packages/netx-topology-mcp/README.md)
|
||||
|
||||
## Useful API endpoints
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
|
||||
1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。
|
||||
2. Admin → MCP → 粘贴 `mcp.json` 全文 → 点击 **Install from JSON**(安装状态在下方一行小字)。
|
||||
3. **Health** → **Sync Tools**(应看到 **14** 个工具)。
|
||||
3. **Health** → **Sync Tools**(应看到 **13** 个工具)。
|
||||
4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。
|
||||
|
||||
更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库:
|
||||
|
|
@ -121,6 +121,8 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
|
||||
可选:oclaw 专用字段展开版 [`mcp_install_payload.json`](../mcp_install_payload.json)(与 `mcp.json` 等价)。
|
||||
|
||||
**拓扑画布 MCP**(独立安装/绑定)见 [`MCP_TOPOLOGY.md`](./MCP_TOPOLOGY.md)(`server_id=netx-topology`,13 个工具)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 暴露的工具
|
||||
|
|
@ -131,9 +133,8 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
| UME 网元 | `queryUmeNeInventory`, `getUmeNe` |
|
||||
| UME 原始/SQL | `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme` |
|
||||
| 托管网元 CLI | `listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets` |
|
||||
| 拓扑 Fabric | `queryTopologyEdges`(`node_id` → A 的链路与 `peer_count` 互联网元数) |
|
||||
|
||||
物理拓扑仅 LLDP;分页查询,勿默认拉全图。oclaw 中名称带前缀:`mcp__netx__<toolName>`。
|
||||
拓扑 Fabric / 画布工具已拆到 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**(含原 `queryTopologyEdges`)。oclaw 中名称带前缀:`mcp__netx__<toolName>`。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
118
docs/MCP_TOPOLOGY.md
Normal file
118
docs/MCP_TOPOLOGY.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# netx Topology MCP — 安装与更新
|
||||
|
||||
与告警/CLI 的 [`netx-mcp`](./MCP.md) **分开**的 stdio MCP,只提供 **拓扑树、画布(views)、Fabric 边/邻接**。Agent 可只装本包,或与 `netx` 并存。
|
||||
|
||||
```
|
||||
MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → /v1/topology/*
|
||||
```
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| **netx API** | 需已启动,拓扑数据在服务端 |
|
||||
| **netx-topology-mcp** | 轻量 HTTP 客户端,与宿主同机 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 安装
|
||||
|
||||
```powershell
|
||||
cd D:\project\chatgpt\netx
|
||||
pip install -e ./packages/netx-topology-mcp
|
||||
python -c "import netx_topology_mcp; print('ok')"
|
||||
python -m netx_topology_mcp
|
||||
```
|
||||
|
||||
从 GitHub:
|
||||
|
||||
```powershell
|
||||
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp"
|
||||
```
|
||||
|
||||
环境变量与 [`MCP.md`](./MCP.md) 相同:`NETX_API_URL`、`NETX_API_TOKEN` / `data/auth/mcp_token`、`NETX_LANG`。
|
||||
|
||||
---
|
||||
|
||||
## 2. Cursor / oclaw 配置
|
||||
|
||||
独立服务器 id:`netx-topology`(不要与 `netx` 混在同一个 command 里)。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"netx-topology": {
|
||||
"command": "python",
|
||||
"args": ["-m", "netx_topology_mcp"],
|
||||
"env": {
|
||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
||||
"NETX_LANG": "zh",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
样本:[`packages/netx-topology-mcp/mcp.json`](../packages/netx-topology-mcp/mcp.json)。
|
||||
|
||||
与告警 MCP 并存时,把两个 server 都放进 `mcpServers` 即可;未勾选/未安装的不会加载工具。
|
||||
|
||||
oclaw:Install from JSON → Health → Sync Tools(应看到 **13** 个工具)→ 专家绑定勾选 `server_id=netx-topology`。
|
||||
|
||||
配套 Agent Skill(画图流水线 / 安全约束):[`.cursor/skills/netx-topology/SKILL.md`](../.cursor/skills/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。
|
||||
|
||||
---
|
||||
|
||||
## 3. 工具一览
|
||||
|
||||
### 读
|
||||
|
||||
| 工具 | 作用 |
|
||||
|------|------|
|
||||
| `getTopologyTree` | 站点/区域文件夹树 + 下属画布 |
|
||||
| `listTopologyViews` / `getTopologyView` | 画布列表 / 单图(节点+边+坐标) |
|
||||
| `getTopologyFabricSummary` | Fabric 汇总 |
|
||||
| `listTopologyFabricNodes` / `searchTopologyFabricNodes` | 网元搜索 |
|
||||
| `queryTopologyNeighborhood` | 指定节点邻接 |
|
||||
| `queryTopologyEdges` | LLDP/手工链路(含 `peer_count`) |
|
||||
|
||||
### 写(只动画布,不污染 Fabric)
|
||||
|
||||
| 工具 | 作用 |
|
||||
|------|------|
|
||||
| `createTopologyView` | 在 folder 下新建画布 |
|
||||
| `addTopologyViewNodes` | **仅** `fabric_node_ids` 放到画布(拒绝 managed/UME,避免创建 Fabric 占位) |
|
||||
| `removeTopologyViewNodes` | 从画布移除(不删 Fabric) |
|
||||
| `updateTopologyViewPositions` | 设置坐标 |
|
||||
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布 |
|
||||
|
||||
**刻意不提供:** 手工建链、`populate`(会经 managed 创建 Fabric 占位)、删 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。
|
||||
|
||||
**前端能否看着画:** 在拓扑页左侧树或右侧浏览区点 **「实时同步」**(默认关闭;**不需要先打开某张图**)。开启后树约每 5 秒、已打开的图约每 3 秒拉取,可看到 MCP 新建区域/画布并往上加点。有未保存本地拖动时不会覆盖你的编辑。
|
||||
|
||||
---
|
||||
|
||||
## 4. 与 netx-mcp 的关系
|
||||
|
||||
| 包 | server_id | 职责 |
|
||||
|----|-----------|------|
|
||||
| `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) |
|
||||
| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 安全画图(**13** 工具) |
|
||||
|
||||
`queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。
|
||||
|
||||
---
|
||||
|
||||
## 5. 更新
|
||||
|
||||
```powershell
|
||||
cd <netx 仓库>
|
||||
git pull
|
||||
pip install -e ./packages/netx-topology-mcp
|
||||
```
|
||||
|
||||
然后重启 MCP 宿主,并 Sync Tools。
|
||||
10
mcp.json
10
mcp.json
|
|
@ -9,6 +9,16 @@
|
|||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1"
|
||||
}
|
||||
},
|
||||
"netx-topology": {
|
||||
"command": "python",
|
||||
"args": ["-m", "netx_topology_mcp"],
|
||||
"env": {
|
||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
||||
"NETX_LANG": "zh",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
mcp_topology_install_payload.json
Normal file
28
mcp_topology_install_payload.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"source_type": "local",
|
||||
"source_ref": "netx-topology-mcp",
|
||||
"server_id": "netx-topology",
|
||||
"version": "0.1.0",
|
||||
"entry_command": "python",
|
||||
"entry_args": ["-m", "netx_topology_mcp"],
|
||||
"env_schema": {
|
||||
"NETX_API_URL": {
|
||||
"type": "string",
|
||||
"description": "netx REST API base URL (no trailing slash)",
|
||||
"default": "http://127.0.0.1:8890"
|
||||
},
|
||||
"NETX_API_TOKEN": {
|
||||
"type": "string",
|
||||
"description": "Optional Bearer token when netx API auth is enabled"
|
||||
},
|
||||
"NETX_LANG": {
|
||||
"type": "string",
|
||||
"description": "Response language hint: zh or en",
|
||||
"default": "zh"
|
||||
}
|
||||
},
|
||||
"required_permissions": [],
|
||||
"risk_level": "medium",
|
||||
"enabled": true,
|
||||
"timeout_s": 120
|
||||
}
|
||||
|
|
@ -36,13 +36,13 @@ python -m netx_mcp
|
|||
|
||||
[`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。
|
||||
|
||||
## 工具(14)
|
||||
## 工具(13)
|
||||
|
||||
UME:`queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics`, `queryUmeNeInventory`, `getUmeNe`, `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme`
|
||||
|
||||
托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets`
|
||||
|
||||
拓扑 Fabric:`queryTopologyEdges`(可按 `node_id` 查 A 与多少网元互联,返回 `peer_count`)
|
||||
拓扑画布 / Fabric → 请单独安装 [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。
|
||||
|
||||
## 兼容
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""MCP tool schemas and HTTP-backed handlers (UME + managed NE + topology fabric)."""
|
||||
"""MCP tool schemas and HTTP-backed handlers (UME + managed NE)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -285,61 +285,6 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
|
|||
return http_json("GET", "/v1/cli/targets", params=params)
|
||||
|
||||
|
||||
def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List fabric edges; with node_id, also summarize unique peer NEs (interconnect count)."""
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
node_id = str(args.get("node_id") or "").strip()
|
||||
params: dict[str, Any] = {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"layer": str(args.get("layer") or "physical").strip() or "physical",
|
||||
}
|
||||
if node_id:
|
||||
params["node_id"] = node_id
|
||||
if str(args.get("status") or "").strip():
|
||||
params["status"] = str(args.get("status")).strip()
|
||||
if str(args.get("source") or "").strip():
|
||||
src = str(args.get("source")).strip().lower()
|
||||
if src == "stale":
|
||||
src = "lldp"
|
||||
params["source"] = src
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
out = http_json("GET", "/v1/topology/fabric/edges", params=params)
|
||||
if not isinstance(out, dict):
|
||||
return out
|
||||
items = out.get("items") if isinstance(out.get("items"), list) else []
|
||||
# When scoping to one NE: unique peers on this page (+ total edges from API).
|
||||
if node_id and items:
|
||||
peers: set[str] = set()
|
||||
peer_labels: list[dict[str, str]] = []
|
||||
seen_label: set[str] = set()
|
||||
for e in items:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
a_id = str(e.get("a_node_id") or "")
|
||||
b_id = str(e.get("b_node_id") or "")
|
||||
if a_id == node_id:
|
||||
peer_id, pname, pip = b_id, str(e.get("b_name") or ""), str(e.get("b_ip") or "")
|
||||
elif b_id == node_id:
|
||||
peer_id, pname, pip = a_id, str(e.get("a_name") or ""), str(e.get("a_ip") or "")
|
||||
else:
|
||||
continue
|
||||
if not peer_id or peer_id in peers:
|
||||
continue
|
||||
peers.add(peer_id)
|
||||
if peer_id not in seen_label:
|
||||
seen_label.add(peer_id)
|
||||
peer_labels.append({"node_id": peer_id, "name": pname, "ip": pip})
|
||||
out["peer_count"] = len(peers)
|
||||
out["peers"] = peer_labels
|
||||
out["edge_total"] = int(out.get("total") or len(items))
|
||||
# Incomplete if caller didn't fetch all pages.
|
||||
out["peers_complete"] = int(out.get("total") or 0) <= len(items)
|
||||
return out
|
||||
|
||||
|
||||
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "queryUmeAlarms",
|
||||
|
|
@ -524,34 +469,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "queryTopologyEdges",
|
||||
"description": (
|
||||
"Query fabric LLDP/manual links. Pass node_id to list edges of NE A and get peer_count "
|
||||
"(how many distinct NEs A interconnects with). Optional keyword filters by endpoint name/IP; "
|
||||
"status=active|missing. Raise page_size if peers_complete is false."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node_id": {
|
||||
"type": "string",
|
||||
"description": "Fabric node id of NE A — returns its edges + peer_count/peers summary",
|
||||
},
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"description": "Filter edges whose endpoint name/IP contains this text",
|
||||
},
|
||||
"layer": {"type": "string", "default": "physical"},
|
||||
"status": {"type": "string", "enum": ["active", "missing", "stale"]},
|
||||
"source": {"type": "string", "enum": ["lldp", "manual"]},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
||||
|
|
@ -568,7 +485,6 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
|||
"getManagedNe": _get_managed_ne,
|
||||
"execManagedNe": _exec_managed_ne,
|
||||
"listCliTargets": _list_cli_targets,
|
||||
"queryTopologyEdges": _query_topology_edges,
|
||||
}
|
||||
|
||||
# Minimum scope required to advertise / invoke each tool (matches netx API RBAC).
|
||||
|
|
@ -586,7 +502,6 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
|
|||
"getManagedNe": "ne:read",
|
||||
"execManagedNe": "ne:exec",
|
||||
"listCliTargets": "ne:read",
|
||||
"queryTopologyEdges": "ne:read",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ from netx_mcp.server import _fetch_scopes
|
|||
|
||||
def test_http_mcp_tool_list_has_expected_tools() -> None:
|
||||
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
|
||||
assert len(names) == 14
|
||||
assert len(names) == 13
|
||||
assert "queryUmeAlarms" in names
|
||||
assert "queryUmeAlarmsRaw" in names
|
||||
assert "execManagedNe" in names
|
||||
assert "listCliTargets" in names
|
||||
assert "queryTopologyEdges" not in names
|
||||
exec_tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe")
|
||||
assert exec_tool["inputSchema"]["properties"]["commands"]["maxItems"] >= 5
|
||||
|
||||
|
|
@ -105,6 +106,11 @@ def test_tools_for_scopes_filters_by_granted() -> None:
|
|||
|
||||
|
||||
def test_stdio_initialize_and_tools_list() -> None:
|
||||
import os
|
||||
|
||||
env = os.environ.copy()
|
||||
env["NETX_API_URL"] = "http://127.0.0.1:1"
|
||||
env.pop("NETX_API_TOKEN", None)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "netx_mcp"],
|
||||
stdin=subprocess.PIPE,
|
||||
|
|
@ -113,6 +119,7 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
)
|
||||
assert proc.stdin and proc.stdout
|
||||
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
|
||||
|
|
@ -129,7 +136,7 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
list_resp = json.loads(list_line)
|
||||
assert "error" not in list_resp, list_resp
|
||||
tools = list_resp["result"]["tools"]
|
||||
assert len(tools) == 14
|
||||
assert len(tools) == 13
|
||||
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
|
|
|||
39
packages/netx-topology-mcp/README.md
Normal file
39
packages/netx-topology-mcp/README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# netx-topology-mcp
|
||||
|
||||
独立的 **stdio MCP**,只暴露 netx **拓扑画布 / Fabric** 能力,与告警/CLI 的 [`netx-mcp`](../netx-mcp) 分开安装,方便 Agent 按需启用。
|
||||
|
||||
```
|
||||
MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → netx API /v1/topology/*
|
||||
```
|
||||
|
||||
详细说明 → **[docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md)**
|
||||
|
||||
配套 Skill → **[`.cursor/skills/netx-topology`](../../.cursor/skills/netx-topology/SKILL.md)**(画图流水线与硬规则)
|
||||
|
||||
## 安装
|
||||
|
||||
```powershell
|
||||
cd D:\project\chatgpt\netx
|
||||
pip install -e ./packages/netx-topology-mcp
|
||||
python -c "import netx_topology_mcp; print('ok')"
|
||||
```
|
||||
|
||||
GitHub:
|
||||
|
||||
```powershell
|
||||
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp"
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
复制 [`mcp.json`](./mcp.json) 到 Cursor / oclaw(`server_id=netx-topology`),可与 `netx` 同时存在。
|
||||
|
||||
## 工具(13)
|
||||
|
||||
| 类别 | 工具 |
|
||||
|------|------|
|
||||
| 树/画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` |
|
||||
| 画图 | `addTopologyViewNodes`(仅已有 `fabric_node_ids`), `removeTopologyViewNodes`, `updateTopologyViewPositions`, `projectTopologyNeighbors` |
|
||||
| Fabric 只读 | `getTopologyFabricSummary`, `listTopologyFabricNodes`, `searchTopologyFabricNodes`, `queryTopologyNeighborhood`, `queryTopologyEdges` |
|
||||
|
||||
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。
|
||||
14
packages/netx-topology-mcp/mcp.json
Normal file
14
packages/netx-topology-mcp/mcp.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"netx-topology": {
|
||||
"command": "python",
|
||||
"args": ["-m", "netx_topology_mcp"],
|
||||
"env": {
|
||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
||||
"NETX_LANG": "zh",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/netx-topology-mcp/pyproject.toml
Normal file
20
packages/netx-topology-mcp/pyproject.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "netx-topology-mcp"
|
||||
version = "0.1.0"
|
||||
description = "stdio MCP server for netx topology canvas (views / fabric / draw)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
dependencies = [
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
netx-topology-mcp = "netx_topology_mcp.server:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""netx topology MCP — canvas / fabric tools for drawing topology maps."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from netx_topology_mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"""HTTP client for netx REST API (topology MCP)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def api_base_url() -> str:
|
||||
raw = (
|
||||
os.getenv("NETX_API_URL")
|
||||
or os.getenv("OCLAW_NETX_BASE_URL")
|
||||
or "http://127.0.0.1:8890"
|
||||
)
|
||||
return str(raw or "").strip().rstrip("/")
|
||||
|
||||
|
||||
def api_headers() -> dict[str, str]:
|
||||
h = {"accept": "application/json"}
|
||||
tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
|
||||
if not tok:
|
||||
candidates = [
|
||||
os.getenv("NETX_MCP_TOKEN_FILE", "").strip(),
|
||||
"data/auth/mcp_token",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "auth", "mcp_token"),
|
||||
]
|
||||
for raw in candidates:
|
||||
if not raw:
|
||||
continue
|
||||
path = os.path.abspath(raw)
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
tok = fh.read().strip()
|
||||
if tok:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if tok:
|
||||
h["authorization"] = f"Bearer {tok}"
|
||||
return h
|
||||
|
||||
|
||||
def lang_query_params() -> dict[str, str]:
|
||||
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
|
||||
if lang.startswith("en"):
|
||||
return {"lang": "en"}
|
||||
return {}
|
||||
|
||||
|
||||
def http_json(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> dict[str, Any]:
|
||||
url = f"{api_base_url()}{path}"
|
||||
merged: dict[str, Any] = dict(lang_query_params())
|
||||
if params:
|
||||
merged.update(params)
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, trust_env=False) as client:
|
||||
resp = client.request(
|
||||
method,
|
||||
url,
|
||||
params=merged or None,
|
||||
json=body,
|
||||
headers=api_headers(),
|
||||
)
|
||||
text = resp.text
|
||||
if not resp.is_success:
|
||||
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
|
||||
data = resp.json() if text else {}
|
||||
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
|
||||
|
||||
|
||||
def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if is_error:
|
||||
out["isError"] = True
|
||||
return out
|
||||
|
||||
|
||||
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
if not result.get("ok"):
|
||||
return mcp_text_result(result, is_error=True)
|
||||
return mcp_text_result(result)
|
||||
467
packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py
Normal file
467
packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
"""MCP tool schemas and HTTP handlers for netx topology canvas / fabric."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from netx_topology_mcp.http_client import http_json, mcp_from_handler_result
|
||||
|
||||
|
||||
def _data(out: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return API payload dict from http_json envelope (or error as-is)."""
|
||||
if not isinstance(out, dict):
|
||||
return {"ok": False, "error": "invalid_response"}
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data")
|
||||
if isinstance(data, dict):
|
||||
merged = dict(data)
|
||||
merged["ok"] = True
|
||||
return merged
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
||||
def _get_topology_tree(_args: dict[str, Any]) -> dict[str, Any]:
|
||||
return _data(http_json("GET", "/v1/topology/tree"))
|
||||
|
||||
|
||||
def _list_topology_views(_args: dict[str, Any]) -> dict[str, Any]:
|
||||
return _data(http_json("GET", "/v1/topology/views"))
|
||||
|
||||
|
||||
def _get_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
||||
view_id = str(args.get("view_id") or "").strip()
|
||||
if not view_id:
|
||||
return {"ok": False, "error": "view_id_required"}
|
||||
return _data(http_json("GET", f"/v1/topology/views/{view_id}"))
|
||||
|
||||
|
||||
def _create_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
||||
name = str(args.get("name") or "").strip()
|
||||
folder_id = str(args.get("folder_id") or "").strip()
|
||||
if not name:
|
||||
return {"ok": False, "error": "name_required"}
|
||||
if not folder_id:
|
||||
return {"ok": False, "error": "folder_id_required"}
|
||||
body: dict[str, Any] = {
|
||||
"name": name,
|
||||
"folder_id": folder_id,
|
||||
"remark": str(args.get("remark") or ""),
|
||||
"kind": str(args.get("kind") or "custom").strip() or "custom",
|
||||
"role": str(args.get("role") or "core").strip() or "core",
|
||||
"sort_order": int(args.get("sort_order") or 0),
|
||||
}
|
||||
filt = args.get("filter")
|
||||
if isinstance(filt, dict):
|
||||
body["filter"] = filt
|
||||
return _data(http_json("POST", "/v1/topology/views", body=body))
|
||||
|
||||
|
||||
def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Place existing fabric nodes on a view only — never create fabric placeholders."""
|
||||
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.",
|
||||
}
|
||||
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))
|
||||
|
||||
|
||||
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})
|
||||
)
|
||||
|
||||
|
||||
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"}
|
||||
if not isinstance(positions, list) or not positions:
|
||||
return {"ok": False, "error": "positions_required"}
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for p in positions:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
fid = str(p.get("fabric_node_id") or "").strip()
|
||||
if not fid:
|
||||
continue
|
||||
cleaned.append(
|
||||
{
|
||||
"fabric_node_id": fid,
|
||||
"x": float(p.get("x") or 0),
|
||||
"y": float(p.get("y") or 0),
|
||||
"label": str(p.get("label") or ""),
|
||||
"locked": bool(p.get("locked") or False),
|
||||
}
|
||||
)
|
||||
if not cleaned:
|
||||
return {"ok": False, "error": "positions_required"}
|
||||
return _data(http_json("PATCH", f"/v1/topology/views/{view_id}/positions", body={"positions": cleaned}))
|
||||
|
||||
|
||||
def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
||||
view_id = str(args.get("view_id") or "").strip()
|
||||
if not view_id:
|
||||
return {"ok": False, "error": "view_id_required"}
|
||||
return _data(http_json("POST", f"/v1/topology/views/{view_id}/project-neighbors", body={}))
|
||||
|
||||
|
||||
def _get_topology_fabric_summary(_args: dict[str, Any]) -> dict[str, Any]:
|
||||
return _data(http_json("GET", "/v1/topology/fabric/summary"))
|
||||
|
||||
|
||||
def _list_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
if str(args.get("role") or "").strip():
|
||||
params["role"] = str(args.get("role")).strip()
|
||||
if str(args.get("link_status") or "").strip():
|
||||
params["link_status"] = str(args.get("link_status")).strip()
|
||||
return _data(http_json("GET", "/v1/topology/fabric/nodes", params=params))
|
||||
|
||||
|
||||
def _search_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
||||
q = str(args.get("q") or args.get("keyword") or "").strip()
|
||||
if not q:
|
||||
return {"ok": False, "error": "q_required"}
|
||||
params: dict[str, Any] = {
|
||||
"q": q,
|
||||
"page": max(1, int(args.get("page") or 1)),
|
||||
"page_size": min(200, max(1, int(args.get("page_size") or args.get("limit") or 50))),
|
||||
}
|
||||
return _data(http_json("GET", "/v1/topology/fabric/nodes/search", params=params))
|
||||
|
||||
|
||||
def _query_topology_neighborhood(args: dict[str, Any]) -> dict[str, Any]:
|
||||
node_id = str(args.get("node_id") or "").strip()
|
||||
if not node_id:
|
||||
return {"ok": False, "error": "node_id_required"}
|
||||
params: dict[str, Any] = {
|
||||
"node_id": node_id,
|
||||
"depth": min(3, max(1, int(args.get("depth") or 1))),
|
||||
"layer": str(args.get("layer") or "physical").strip() or "physical",
|
||||
}
|
||||
return _data(http_json("GET", "/v1/topology/fabric/neighborhood", params=params))
|
||||
|
||||
|
||||
def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List fabric edges; with node_id, also summarize unique peer NEs."""
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 100)))
|
||||
node_id = str(args.get("node_id") or "").strip()
|
||||
params: dict[str, Any] = {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"layer": str(args.get("layer") or "physical").strip() or "physical",
|
||||
}
|
||||
if node_id:
|
||||
params["node_id"] = node_id
|
||||
if str(args.get("status") or "").strip():
|
||||
params["status"] = str(args.get("status")).strip()
|
||||
if str(args.get("source") or "").strip():
|
||||
src = str(args.get("source")).strip().lower()
|
||||
if src == "stale":
|
||||
src = "lldp"
|
||||
params["source"] = src
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
|
||||
out = http_json("GET", "/v1/topology/fabric/edges", params=params)
|
||||
if not isinstance(out, dict) or not out.get("ok"):
|
||||
return out if isinstance(out, dict) else {"ok": False, "error": "invalid_response"}
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else {}
|
||||
items = data.get("items") if isinstance(data.get("items"), list) else []
|
||||
result: dict[str, Any] = {"ok": True, **data}
|
||||
if node_id and items:
|
||||
peers: set[str] = set()
|
||||
peer_labels: list[dict[str, str]] = []
|
||||
seen_label: set[str] = set()
|
||||
for e in items:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
a_id = str(e.get("a_node_id") or "")
|
||||
b_id = str(e.get("b_node_id") or "")
|
||||
if a_id == node_id:
|
||||
peer_id, pname, pip = b_id, str(e.get("b_name") or ""), str(e.get("b_ip") or "")
|
||||
elif b_id == node_id:
|
||||
peer_id, pname, pip = a_id, str(e.get("a_name") or ""), str(e.get("a_ip") or "")
|
||||
else:
|
||||
continue
|
||||
if not peer_id or peer_id in peers:
|
||||
continue
|
||||
peers.add(peer_id)
|
||||
if peer_id not in seen_label:
|
||||
seen_label.add(peer_id)
|
||||
peer_labels.append({"node_id": peer_id, "name": pname, "ip": pip})
|
||||
result["peer_count"] = len(peers)
|
||||
result["peers"] = peer_labels
|
||||
result["edge_total"] = int(data.get("total") or len(items))
|
||||
result["peers_complete"] = int(data.get("total") or 0) <= len(items)
|
||||
return result
|
||||
|
||||
|
||||
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "getTopologyTree",
|
||||
"description": "Get topology folder tree (sites/regions) with nested views — start here before createTopologyView.",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "listTopologyViews",
|
||||
"description": "List topology canvas views (maps).",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "getTopologyView",
|
||||
"description": "Get a topology view graph (nodes + edges + positions) by view_id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"view_id": {"type": "string"}},
|
||||
"required": ["view_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "createTopologyView",
|
||||
"description": "Create a topology canvas under a folder (folder_id from getTopologyTree).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"folder_id": {"type": "string"},
|
||||
"remark": {"type": "string"},
|
||||
"kind": {"type": "string", "enum": ["physical", "custom"], "default": "custom"},
|
||||
"role": {"type": "string", "default": "core"},
|
||||
"sort_order": {"type": "integer", "default": 0},
|
||||
"filter": {"type": "object"},
|
||||
},
|
||||
"required": ["name", "folder_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"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."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"view_id": {"type": "string"},
|
||||
"fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||||
"layout": {"type": "string", "enum": ["grid", "keep"], "default": "grid"},
|
||||
},
|
||||
"required": ["view_id", "fabric_node_ids"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "removeTopologyViewNodes",
|
||||
"description": "Remove fabric nodes from a view canvas (does not delete fabric inventory).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"view_id": {"type": "string"},
|
||||
"fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||||
},
|
||||
"required": ["view_id", "fabric_node_ids"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "updateTopologyViewPositions",
|
||||
"description": "Set x/y positions for fabric nodes on a view (draw / rearrange).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"view_id": {"type": "string"},
|
||||
"positions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fabric_node_id": {"type": "string"},
|
||||
"x": {"type": "number"},
|
||||
"y": {"type": "number"},
|
||||
"label": {"type": "string"},
|
||||
"locked": {"type": "boolean"},
|
||||
},
|
||||
"required": ["fabric_node_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"minItems": 1,
|
||||
},
|
||||
},
|
||||
"required": ["view_id", "positions"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "projectTopologyNeighbors",
|
||||
"description": (
|
||||
"Project existing LLDP fabric neighbors of nodes already on the view onto the canvas. "
|
||||
"Only places nodes that already exist in fabric."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"view_id": {"type": "string"}},
|
||||
"required": ["view_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "getTopologyFabricSummary",
|
||||
"description": "Fabric inventory summary (node/edge counts).",
|
||||
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"name": "listTopologyFabricNodes",
|
||||
"description": "Paged fabric nodes (keyword/role/link_status filters).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"link_status": {
|
||||
"type": "string",
|
||||
"enum": ["linked", "orphaned", "managed", "ume", "both"],
|
||||
},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "searchTopologyFabricNodes",
|
||||
"description": "Quick search fabric nodes by name/IP/id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"q": {"type": "string"},
|
||||
"keyword": {"type": "string", "description": "Alias of q"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50},
|
||||
"limit": {"type": "integer", "description": "Alias of page_size"},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "queryTopologyNeighborhood",
|
||||
"description": "Neighborhood around a fabric node (depth 1–3).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node_id": {"type": "string"},
|
||||
"depth": {"type": "integer", "minimum": 1, "maximum": 3, "default": 1},
|
||||
"layer": {"type": "string", "default": "physical"},
|
||||
},
|
||||
"required": ["node_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "queryTopologyEdges",
|
||||
"description": (
|
||||
"Query fabric LLDP/manual links. Pass node_id for edges of NE A plus peer_count. "
|
||||
"Raise page_size if peers_complete is false."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node_id": {"type": "string"},
|
||||
"keyword": {"type": "string"},
|
||||
"layer": {"type": "string", "default": "physical"},
|
||||
"status": {"type": "string", "enum": ["active", "missing", "stale"]},
|
||||
"source": {"type": "string", "enum": ["lldp", "manual"]},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
||||
"getTopologyTree": _get_topology_tree,
|
||||
"listTopologyViews": _list_topology_views,
|
||||
"getTopologyView": _get_topology_view,
|
||||
"createTopologyView": _create_topology_view,
|
||||
"addTopologyViewNodes": _add_topology_view_nodes,
|
||||
"removeTopologyViewNodes": _remove_topology_view_nodes,
|
||||
"updateTopologyViewPositions": _update_topology_view_positions,
|
||||
"projectTopologyNeighbors": _project_topology_neighbors,
|
||||
"getTopologyFabricSummary": _get_topology_fabric_summary,
|
||||
"listTopologyFabricNodes": _list_topology_fabric_nodes,
|
||||
"searchTopologyFabricNodes": _search_topology_fabric_nodes,
|
||||
"queryTopologyNeighborhood": _query_topology_neighborhood,
|
||||
"queryTopologyEdges": _query_topology_edges,
|
||||
}
|
||||
|
||||
TOOL_REQUIRED_SCOPE: dict[str, str] = {
|
||||
"getTopologyTree": "ne:read",
|
||||
"listTopologyViews": "ne:read",
|
||||
"getTopologyView": "ne:read",
|
||||
"createTopologyView": "ne:write",
|
||||
"addTopologyViewNodes": "ne:write",
|
||||
"removeTopologyViewNodes": "ne:write",
|
||||
"updateTopologyViewPositions": "ne:write",
|
||||
"projectTopologyNeighbors": "ne:write",
|
||||
"getTopologyFabricSummary": "ne:read",
|
||||
"listTopologyFabricNodes": "ne:read",
|
||||
"searchTopologyFabricNodes": "ne:read",
|
||||
"queryTopologyNeighborhood": "ne:read",
|
||||
"queryTopologyEdges": "ne:read",
|
||||
}
|
||||
|
||||
|
||||
def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> list[dict[str, Any]]:
|
||||
if scopes is None:
|
||||
return list(HTTP_MCP_TOOLS)
|
||||
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
|
||||
if not granted:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for tool in HTTP_MCP_TOOLS:
|
||||
name = str(tool.get("name") or "")
|
||||
need = TOOL_REQUIRED_SCOPE.get(name)
|
||||
if need is None or need in granted:
|
||||
out.append(tool)
|
||||
return out
|
||||
|
||||
|
||||
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
fn = _HANDLERS.get(str(name or "").strip())
|
||||
if not fn:
|
||||
raise ValueError(f"unknown tool: {name}")
|
||||
return mcp_from_handler_result(fn(dict(args or {})))
|
||||
122
packages/netx-topology-mcp/src/netx_topology_mcp/server.py
Normal file
122
packages/netx-topology-mcp/src/netx_topology_mcp/server.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""netx topology stdio MCP server (HTTP client to netx REST API).
|
||||
|
||||
Environment:
|
||||
- ``NETX_API_URL``: netx REST base URL (default ``http://127.0.0.1:8890``)
|
||||
- ``NETX_API_TOKEN``: optional Bearer token
|
||||
- ``NETX_LANG``: ``zh`` or ``en``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.http_client import http_json
|
||||
from netx_topology_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
|
||||
|
||||
|
||||
def _ensure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
||||
if stream is None or not hasattr(stream, "reconfigure"):
|
||||
continue
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _ok(rid: Any, result: dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _err(rid: Any, code: int, message: str) -> None:
|
||||
sys.stdout.write(
|
||||
json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False)
|
||||
+ "\n"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _fetch_scopes() -> list[str] | None:
|
||||
try:
|
||||
envelope = http_json("GET", "/v1/auth/me")
|
||||
if not isinstance(envelope, dict) or not envelope.get("ok"):
|
||||
return None
|
||||
data = envelope.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
scopes = data.get("scopes")
|
||||
if isinstance(scopes, list):
|
||||
return [str(s) for s in scopes]
|
||||
user = data.get("user")
|
||||
if isinstance(user, dict) and isinstance(user.get("scopes"), list):
|
||||
return [str(s) for s in user["scopes"]]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def run_stdio_loop() -> None:
|
||||
cached_scopes: list[str] | None | object = _UNSET
|
||||
|
||||
def scopes() -> list[str] | None:
|
||||
nonlocal cached_scopes
|
||||
if cached_scopes is _UNSET:
|
||||
cached_scopes = _fetch_scopes()
|
||||
return cached_scopes # type: ignore[return-value]
|
||||
|
||||
for line in sys.stdin:
|
||||
raw = line.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
rid = req.get("id")
|
||||
method = str(req.get("method") or "")
|
||||
params = req.get("params") if isinstance(req.get("params"), dict) else {}
|
||||
|
||||
try:
|
||||
if method == "initialize":
|
||||
_ok(
|
||||
rid,
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "netx-topology-mcp", "version": "0.1.0", "mode": "http"},
|
||||
},
|
||||
)
|
||||
continue
|
||||
if method == "notifications/initialized":
|
||||
continue
|
||||
if method == "tools/list":
|
||||
_ok(rid, {"tools": tools_for_scopes(scopes())})
|
||||
continue
|
||||
if method == "tools/call":
|
||||
name = str(params.get("name") or "")
|
||||
need = TOOL_REQUIRED_SCOPE.get(name)
|
||||
granted = scopes()
|
||||
if need and granted is not None and need not in {str(s).lower() for s in granted}:
|
||||
_err(rid, -32001, f"insufficient_scope:{need}")
|
||||
continue
|
||||
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
||||
_ok(rid, call_http_tool(name, args))
|
||||
continue
|
||||
_err(rid, -32601, f"method not found: {method}")
|
||||
except Exception as exc:
|
||||
_err(rid, -32000, str(exc))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_ensure_utf8_stdio()
|
||||
run_stdio_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
146
packages/netx-topology-mcp/tests/test_mcp_topology.py
Normal file
146
packages/netx-topology-mcp/tests/test_mcp_topology.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Tests for netx topology HTTP MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool, tools_for_scopes
|
||||
from netx_topology_mcp.server import _fetch_scopes
|
||||
|
||||
|
||||
def test_tool_list_has_draw_and_query_tools() -> None:
|
||||
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
|
||||
assert len(names) == 13
|
||||
assert "createTopologyView" in names
|
||||
assert "addTopologyViewNodes" in names
|
||||
assert "updateTopologyViewPositions" in names
|
||||
assert "queryTopologyEdges" in names
|
||||
assert "getTopologyTree" in names
|
||||
assert "createTopologyManualEdge" not in names
|
||||
assert "populateTopologyView" not in names
|
||||
|
||||
|
||||
def test_add_nodes_rejects_managed_ume_ids() -> None:
|
||||
out = call_http_tool(
|
||||
"addTopologyViewNodes",
|
||||
{"view_id": "v1", "managed_ne_ids": ["m1"], "fabric_node_ids": ["f1"]},
|
||||
)
|
||||
assert out.get("isError") is True
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["error"] == "fabric_nodes_only"
|
||||
|
||||
|
||||
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": []}}
|
||||
out = call_http_tool(
|
||||
"addTopologyViewNodes",
|
||||
{"view_id": "v1", "fabric_node_ids": ["f1", "f2"], "layout": "grid"},
|
||||
)
|
||||
body = mock_http.call_args[1]["body"]
|
||||
assert body["fabric_node_ids"] == ["f1", "f2"]
|
||||
assert body["managed_ne_ids"] == []
|
||||
assert body["ume_ne_ids"] == []
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_create_view_requires_folder() -> None:
|
||||
out = call_http_tool("createTopologyView", {"name": "map1"})
|
||||
assert out.get("isError") is True
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["error"] == "folder_id_required"
|
||||
|
||||
|
||||
def test_create_view_posts_body() -> None:
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"id": "v1", "name": "map1"}}
|
||||
out = call_http_tool(
|
||||
"createTopologyView",
|
||||
{"name": "map1", "folder_id": "f1", "kind": "custom"},
|
||||
)
|
||||
mock_http.assert_called_once()
|
||||
assert mock_http.call_args[0][0] == "POST"
|
||||
assert mock_http.call_args[0][1] == "/v1/topology/views"
|
||||
body = mock_http.call_args[1]["body"]
|
||||
assert body["name"] == "map1"
|
||||
assert body["folder_id"] == "f1"
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_query_edges_enriches_peers() -> None:
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"a_node_id": "A",
|
||||
"b_node_id": "B",
|
||||
"a_name": "ne-a",
|
||||
"b_name": "ne-b",
|
||||
"a_ip": "1.1.1.1",
|
||||
"b_ip": "2.2.2.2",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
out = call_http_tool("queryTopologyEdges", {"node_id": "A", "page_size": 100})
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
assert payload["peer_count"] == 1
|
||||
assert payload["peers"][0]["node_id"] == "B"
|
||||
assert payload["peers_complete"] is True
|
||||
|
||||
|
||||
def test_tools_for_scopes_filters_write() -> None:
|
||||
read_only = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read"])}
|
||||
assert "queryTopologyEdges" in read_only
|
||||
assert "createTopologyView" not in read_only
|
||||
write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])}
|
||||
assert "createTopologyView" in write
|
||||
|
||||
|
||||
def test_fetch_scopes_unwraps_envelope() -> None:
|
||||
with patch("netx_topology_mcp.server.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"scopes": ["ne:read", "ne:write"]}}
|
||||
assert _fetch_scopes() == ["ne:read", "ne:write"]
|
||||
|
||||
|
||||
def test_stdio_initialize_and_tools_list() -> None:
|
||||
import os
|
||||
|
||||
# Force scopes fetch to fail so tools/list returns the full catalog.
|
||||
env = os.environ.copy()
|
||||
env["NETX_API_URL"] = "http://127.0.0.1:1"
|
||||
env.pop("NETX_API_TOKEN", None)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "netx_topology_mcp"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
)
|
||||
assert proc.stdin and proc.stdout
|
||||
try:
|
||||
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n")
|
||||
proc.stdin.flush()
|
||||
init_resp = json.loads(proc.stdout.readline())
|
||||
assert init_resp["result"]["serverInfo"]["name"] == "netx-topology-mcp"
|
||||
|
||||
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n")
|
||||
proc.stdin.flush()
|
||||
list_resp = json.loads(proc.stdout.readline())
|
||||
tools = list_resp["result"]["tools"]
|
||||
assert len(tools) == 13
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
|
@ -32,9 +32,11 @@ dependencies = [
|
|||
|
||||
[project.optional-dependencies]
|
||||
mcp = ["netx-mcp @ file:packages/netx-mcp"]
|
||||
mcp-topology = ["netx-topology-mcp @ file:packages/netx-topology-mcp"]
|
||||
|
||||
[project.scripts]
|
||||
netx-mcp = "netx_mcp.server:main"
|
||||
netx-topology-mcp = "netx_topology_mcp.server:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
|
|
|
|||
|
|
@ -234,6 +234,29 @@ class AuthApiTests(unittest.TestCase):
|
|||
r = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {api_tok}"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_api_token_with_scopes(self) -> None:
|
||||
token = self._login()
|
||||
created = self.client.post(
|
||||
"/v1/api-tokens",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"name": "topo-write",
|
||||
"expires_in_days": 30,
|
||||
"scopes": ["ne:read", "ne:write", "alarms:read"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(created.status_code, 200, created.text)
|
||||
body = created.json()["token"]
|
||||
self.assertEqual(sorted(body.get("scopes") or []), ["alarms:read", "ne:read", "ne:write"])
|
||||
api_tok = body["token"]
|
||||
me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {api_tok}"})
|
||||
self.assertEqual(me.status_code, 200, me.text)
|
||||
granted = sorted(me.json().get("scopes") or [])
|
||||
self.assertIn("ne:write", granted)
|
||||
self.assertIn("ne:read", granted)
|
||||
# Token cannot escalate beyond listed scopes (admin owner still capped by token list).
|
||||
self.assertNotIn("admin:users", granted)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
BIN
web/public/login-earth-night.webp
Normal file
BIN
web/public/login-earth-night.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 596 KiB |
BIN
web/public/login-space.webp
Normal file
BIN
web/public/login-space.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
|
|
@ -477,7 +477,7 @@ const en = {
|
|||
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.",
|
||||
"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.",
|
||||
tokenName: "Name",
|
||||
expiresIn: "Expiry",
|
||||
expire7d: "7 days",
|
||||
|
|
@ -501,6 +501,24 @@ 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",
|
||||
scopesCol: "Scopes",
|
||||
scopesRequired: "Select at least one scope, or inherit all owner scopes",
|
||||
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",
|
||||
forceChangeTitle: "Change initial password",
|
||||
forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.",
|
||||
oldPassword: "Current password",
|
||||
|
|
@ -1375,6 +1393,9 @@ const en = {
|
|||
edgeDiscovered: "Discovered",
|
||||
edgeStale: "Missing",
|
||||
fit: "Fit view",
|
||||
liveSync: "Live sync",
|
||||
liveSyncOn: "Syncing…",
|
||||
liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default. No need to open a map first",
|
||||
fullscreen: "Fullscreen",
|
||||
exitFullscreen: "Exit fullscreen",
|
||||
display: "Display",
|
||||
|
|
|
|||
|
|
@ -473,7 +473,7 @@ const zh = {
|
|||
colStatus: "状态码",
|
||||
colIp: "IP",
|
||||
apiKeysTitle: "API Key 管理",
|
||||
apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。",
|
||||
apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。可勾选权限;拓扑画图需 ne:write。",
|
||||
tokenName: "名称",
|
||||
expiresIn: "有效期",
|
||||
expire7d: "7 天",
|
||||
|
|
@ -497,6 +497,23 @@ const zh = {
|
|||
tokenStatusRevoked: "已吊销",
|
||||
revokeToken: "吊销",
|
||||
revokeConfirm: "确定吊销该 API Key?",
|
||||
scopesTitle: "权限范围",
|
||||
scopesHint: "勾选后写入 Token;不勾选「继承」时至少选一项。默认 MCP 不含 ne:write,写工具对 Agent 不可见。",
|
||||
scopesInherit: "继承所属用户全部权限",
|
||||
scopesInheritShort: "继承用户",
|
||||
scopesCol: "权限",
|
||||
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 运维写入",
|
||||
forceChangeTitle: "请修改初始密码",
|
||||
forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。",
|
||||
oldPassword: "当前密码",
|
||||
|
|
@ -1369,6 +1386,9 @@ const zh = {
|
|||
edgeDiscovered: "发现",
|
||||
edgeStale: "未发现",
|
||||
fit: "适应画布",
|
||||
liveSync: "实时同步",
|
||||
liveSyncOn: "同步中…",
|
||||
liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新已打开的图(观看 MCP 建图/画拓扑);默认关闭。不需要先打开画布",
|
||||
fullscreen: "全屏显示",
|
||||
exitFullscreen: "退出全屏",
|
||||
display: "显示",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
|
|
@ -11,6 +11,7 @@ type TokenRow = {
|
|||
name: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
scopes?: string[];
|
||||
created_at: string | null;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
|
|
@ -23,9 +24,35 @@ type UserRow = {
|
|||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
scopes?: string[];
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
const ALL_SCOPE_KEYS = [
|
||||
"alarms:read",
|
||||
"ne:read",
|
||||
"ne:write",
|
||||
"ne:exec",
|
||||
"webcrt:session",
|
||||
"sql:query",
|
||||
"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;
|
||||
|
||||
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",
|
||||
"admin:users": "auth.scopeAdminUsers",
|
||||
"ops:write": "auth.scopeOpsWrite",
|
||||
};
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: 7, labelKey: "auth.expire7d" },
|
||||
{ value: 30, labelKey: "auth.expire30d" },
|
||||
|
|
@ -40,14 +67,21 @@ function tokenStatusClass(row: TokenRow): string {
|
|||
return "pt-list-status--ok";
|
||||
}
|
||||
|
||||
function intersectScopes(available: string[], desired: readonly string[]): string[] {
|
||||
const allow = new Set(available);
|
||||
return desired.filter((s) => allow.has(s));
|
||||
}
|
||||
|
||||
export function ApiTokensPage() {
|
||||
const { t } = useI18n();
|
||||
const { ready, user, isAdmin } = useAuth();
|
||||
const { ready, user, isAdmin, scopes: myScopes } = useAuth();
|
||||
const { showOk, showError } = useToast();
|
||||
const qc = useQueryClient();
|
||||
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 tokensQuery = useQuery({
|
||||
|
|
@ -62,12 +96,32 @@ export function ApiTokensPage() {
|
|||
enabled: ready && isAdmin,
|
||||
});
|
||||
|
||||
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
|
||||
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
|
||||
|
||||
const availableScopes = useMemo(() => {
|
||||
if (ownerUserId) {
|
||||
const owner = users.find((u) => u.id === ownerUserId);
|
||||
return [...(owner?.scopes || [])].sort();
|
||||
}
|
||||
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 createMut = useMutation({
|
||||
mutationFn: () =>
|
||||
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,
|
||||
}),
|
||||
onSuccess: async (data) => {
|
||||
setCreatedPlain(data.token.token);
|
||||
|
|
@ -86,11 +140,12 @@ export function ApiTokensPage() {
|
|||
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
|
||||
});
|
||||
|
||||
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
|
||||
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
|
||||
|
||||
const onCreate = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inheritScopes && selectedScopes.length === 0) {
|
||||
showError(t("auth.scopesRequired"));
|
||||
return;
|
||||
}
|
||||
setCreatedPlain("");
|
||||
createMut.mutate();
|
||||
};
|
||||
|
|
@ -104,6 +159,22 @@ export function ApiTokensPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
setSelectedScopes((prev) =>
|
||||
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope].sort(),
|
||||
);
|
||||
};
|
||||
|
||||
const applyPreset = (desired: readonly string[]) => {
|
||||
setInheritScopes(false);
|
||||
setSelectedScopes(intersectScopes(availableScopes, desired));
|
||||
};
|
||||
|
||||
const formatScopes = (scopes: string[] | undefined) => {
|
||||
if (!scopes || scopes.length === 0) return t("auth.scopesInheritShort");
|
||||
return scopes.join(", ");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack system-page">
|
||||
<section className="panel">
|
||||
|
|
@ -113,43 +184,91 @@ export function ApiTokensPage() {
|
|||
<p className="panel__hint">{t("auth.apiKeysHint")}</p>
|
||||
|
||||
<div className="pt-list">
|
||||
<form className="filter-inline" onSubmit={onCreate}>
|
||||
<input
|
||||
placeholder={t("auth.tokenName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||
aria-label={t("auth.expiresIn")}
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isAdmin ? (
|
||||
<form className="token-create" onSubmit={onCreate}>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
placeholder={t("auth.tokenName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={ownerUserId}
|
||||
onChange={(e) => setOwnerUserId(e.target.value)}
|
||||
aria-label={t("auth.tokenOwner")}
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||
aria-label={t("auth.expiresIn")}
|
||||
>
|
||||
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
|
||||
{users
|
||||
.filter((u) => u.is_active)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
{EXPIRY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<button type="submit" disabled={createMut.isPending}>
|
||||
{t("auth.createToken")}
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<select
|
||||
value={ownerUserId}
|
||||
onChange={(e) => setOwnerUserId(e.target.value)}
|
||||
aria-label={t("auth.tokenOwner")}
|
||||
>
|
||||
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
|
||||
{users
|
||||
.filter((u) => u.is_active)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<button type="submit" disabled={createMut.isPending}>
|
||||
{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 ? (
|
||||
|
|
@ -177,6 +296,7 @@ export function ApiTokensPage() {
|
|||
<tr>
|
||||
<th>{t("auth.tokenName")}</th>
|
||||
<th>{t("auth.tokenOwner")}</th>
|
||||
<th>{t("auth.scopesCol")}</th>
|
||||
<th>{t("auth.colTime")}</th>
|
||||
<th>{t("auth.expiresAt")}</th>
|
||||
<th>{t("auth.lastUsed")}</th>
|
||||
|
|
@ -189,6 +309,9 @@ export function ApiTokensPage() {
|
|||
<tr key={row.id}>
|
||||
<td className="pt-list-task-name">{row.name}</td>
|
||||
<td>{row.username || row.user_id}</td>
|
||||
<td className="token-scopes-cell" title={formatScopes(row.scopes)}>
|
||||
{formatScopes(row.scopes)}
|
||||
</td>
|
||||
<td className="pt-list-time">
|
||||
{row.created_at ? formatSystemTime(row.created_at) : t("common.empty")}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
|
|||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
import { apiPost } from "../services/api";
|
||||
import { LoginShell } from "./LoginShell";
|
||||
|
||||
export function ForceChangePasswordPage() {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -42,26 +43,24 @@ export function ForceChangePasswordPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-page__atmosphere" aria-hidden="true">
|
||||
<span className="login-page__orb login-page__orb--a" />
|
||||
<span className="login-page__orb login-page__orb--b" />
|
||||
<span className="login-page__grid" />
|
||||
</div>
|
||||
<LoginShell>
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
<div className="login-card__head">
|
||||
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
|
||||
<p className="login-card__hint">
|
||||
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
|
||||
</p>
|
||||
<label className="login-card__label">
|
||||
{t("auth.oldPassword")}
|
||||
<span className="login-card__sr">{t("auth.oldPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
placeholder={t("auth.oldPassword")}
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
|
|
@ -69,10 +68,11 @@ export function ForceChangePasswordPage() {
|
|||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
{t("auth.newPassword")}
|
||||
<span className="login-card__sr">{t("auth.newPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("auth.newPassword")}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
|
|
@ -81,10 +81,11 @@ export function ForceChangePasswordPage() {
|
|||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
{t("auth.confirmPassword")}
|
||||
<span className="login-card__sr">{t("auth.confirmPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("auth.confirmPassword")}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
disabled={busy}
|
||||
|
|
@ -92,20 +93,23 @@ export function ForceChangePasswordPage() {
|
|||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
{error ? <div className="login-card__error">{error}</div> : null}
|
||||
{error ? (
|
||||
<div className="login-card__error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button type="submit" className="login-card__submit" disabled={busy}>
|
||||
{busy ? t("auth.savingPassword") : t("auth.savePassword")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-card__submit"
|
||||
style={{ background: "#64748b" }}
|
||||
className="login-card__submit login-card__submit--ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
|
|||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
import { LoginShell } from "./LoginShell";
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -9,6 +10,7 @@ export function LoginPage() {
|
|||
const [params] = useSearchParams();
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
|
|
@ -31,36 +33,66 @@ export function LoginPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-page__atmosphere" aria-hidden="true">
|
||||
<span className="login-page__orb login-page__orb--a" />
|
||||
<span className="login-page__orb login-page__orb--b" />
|
||||
<span className="login-page__grid" />
|
||||
</div>
|
||||
<LoginShell>
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
<div className="login-card__head">
|
||||
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
|
||||
<label className="login-card__label">
|
||||
{t("auth.username")}
|
||||
<span className="login-card__sr">{t("auth.username")}</span>
|
||||
<input
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
{t("auth.password")}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
<span className="login-card__sr">{t("auth.password")}</span>
|
||||
<span className="login-card__password">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
placeholder={t("auth.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-card__eye"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3 3l18 18M10.6 10.6a2.5 2.5 0 0 0 3.5 3.5M9.9 5.1A10 10 0 0 1 12 4.8c5.5 0 9.2 5.3 10.2 6.7a1.2 1.2 0 0 1 0 1.4c-.4.6-1.3 1.8-2.7 3M6.1 6.1C4.2 7.5 2.9 9.3 2 11.1a1.2 1.2 0 0 0 0 1.4C3 13.9 6.7 19.2 12 19.2c1.4 0 2.7-.3 3.9-.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 12.2C3 10.5 6.7 4.8 12 4.8s9 5.7 10 7.4a1.2 1.2 0 0 1 0 1.2C21 15.1 17.3 20.8 12 20.8S3 15.1 2 13.4a1.2 1.2 0 0 1 0-1.2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12.8" r="3" stroke="currentColor" strokeWidth="1.75" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="login-card__error" role="alert">
|
||||
|
|
@ -71,6 +103,6 @@ export function LoginPage() {
|
|||
{busy ? t("auth.loggingIn") : t("auth.login")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
18
web/src/pages/LoginShell.tsx
Normal file
18
web/src/pages/LoginShell.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
/** Auth shell modeled after CuteCloud login: space BG + rotating night-earth + centered form. */
|
||||
export function LoginShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-page__space" aria-hidden="true">
|
||||
<div className="login-planet">
|
||||
<div className="login-planet__railway" />
|
||||
<div className="login-planet__earth">
|
||||
<div className="login-planet__textures" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-page__content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -597,6 +597,22 @@ function flowToPositions(nodes: Node<NeNodeData>[]) {
|
|||
}));
|
||||
}
|
||||
|
||||
/** Stable signature so live poll only re-applies when the server graph actually changed. */
|
||||
function graphFingerprint(graph: TopologyViewGraph): string {
|
||||
const nodes = [...graph.nodes]
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}`,
|
||||
)
|
||||
.sort()
|
||||
.join("|");
|
||||
const edges = [...graph.edges]
|
||||
.map((e) => `${e.id}:${e.a_node_id}:${e.b_node_id}:${e.a_port || ""}:${e.b_port || ""}:${e.status || ""}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}`;
|
||||
}
|
||||
|
||||
function applyViewGraph(
|
||||
graph: TopologyViewGraph,
|
||||
defaults: EdgeDefaults,
|
||||
|
|
@ -678,6 +694,8 @@ export function TopologyPage() {
|
|||
});
|
||||
const [discoverError, setDiscoverError] = useState("");
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
/** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */
|
||||
const [liveSync, setLiveSync] = useState(false);
|
||||
const [canvasQuery, setCanvasQuery] = useState("");
|
||||
const [searchHitIds, setSearchHitIds] = useState<string[]>([]);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
|
|
@ -692,6 +710,8 @@ export function TopologyPage() {
|
|||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
const appliedMapIdRef = useRef("");
|
||||
const appliedGraphFpRef = useRef("");
|
||||
const nodesRef = useRef<Node<NeNodeData>[]>([]);
|
||||
const historyRef = useRef<HistorySnap[]>([]);
|
||||
const redoRef = useRef<HistorySnap[]>([]);
|
||||
const historyLockRef = useRef(false);
|
||||
|
|
@ -699,6 +719,8 @@ export function TopologyPage() {
|
|||
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
||||
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
||||
|
||||
nodesRef.current = nodes;
|
||||
|
||||
const markDirty = useCallback(() => {
|
||||
dirtyRef.current = true;
|
||||
setDirty(true);
|
||||
|
|
@ -760,6 +782,8 @@ export function TopologyPage() {
|
|||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
refetchInterval: liveSync ? 5000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -777,6 +801,8 @@ export function TopologyPage() {
|
|||
queryKey: queryKeys.topologyGraph(mapId),
|
||||
queryFn: () => fetchTopologyGraph(mapId),
|
||||
enabled: Boolean(mapId),
|
||||
refetchInterval: liveSync && mapId ? 3000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -915,24 +941,41 @@ export function TopologyPage() {
|
|||
|
||||
useEffect(() => {
|
||||
appliedMapIdRef.current = "";
|
||||
appliedGraphFpRef.current = "";
|
||||
}, [mapId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasMode) return;
|
||||
if (!mapId || !graphQuery.data) return;
|
||||
// Only hydrate React Flow from server when entering a map — never clobber unsaved local positions.
|
||||
if (appliedMapIdRef.current === mapId) return;
|
||||
appliedMapIdRef.current = mapId;
|
||||
const entering = appliedMapIdRef.current !== mapId;
|
||||
// Live poll while on the same map: apply server graph only if no unsaved local edits.
|
||||
if (!entering && dirtyRef.current) return;
|
||||
|
||||
const fp = graphFingerprint(graphQuery.data);
|
||||
if (!entering && fp === appliedGraphFpRef.current) return;
|
||||
|
||||
const selectedIds = entering
|
||||
? new Set<string>()
|
||||
: new Set(nodesRef.current.filter((n) => n.selected).map((n) => n.id));
|
||||
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults);
|
||||
const nextNodes =
|
||||
selectedIds.size > 0
|
||||
? rfNodes.map((n) => (selectedIds.has(n.id) ? { ...n, selected: true } : n))
|
||||
: rfNodes;
|
||||
|
||||
historyLockRef.current = true;
|
||||
setNodes(rfNodes);
|
||||
setNodes(nextNodes);
|
||||
setEdges(rfEdges);
|
||||
historyRef.current = [];
|
||||
redoRef.current = [];
|
||||
clearDirty();
|
||||
bumpHistory();
|
||||
appliedGraphFpRef.current = fp;
|
||||
if (entering) {
|
||||
appliedMapIdRef.current = mapId;
|
||||
historyRef.current = [];
|
||||
redoRef.current = [];
|
||||
clearDirty();
|
||||
bumpHistory();
|
||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||
}
|
||||
historyLockRef.current = false;
|
||||
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
|
||||
}, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -2119,17 +2162,29 @@ export function TopologyPage() {
|
|||
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
|
||||
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="topo-sidebar__rail"
|
||||
title={t("topology.expandSidebar")}
|
||||
aria-label={t("topology.expandSidebar")}
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
>
|
||||
<span className="topo-sidebar__rail-icon" aria-hidden="true">
|
||||
<SidebarFoldIcon expand />
|
||||
</span>
|
||||
</button>
|
||||
<div className="topo-sidebar__rail-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-sidebar__rail"
|
||||
title={t("topology.expandSidebar")}
|
||||
aria-label={t("topology.expandSidebar")}
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
>
|
||||
<span className="topo-sidebar__rail-icon" aria-hidden="true">
|
||||
<SidebarFoldIcon expand />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`topo-sidebar__rail topo-sidebar__rail--live${liveSync ? " is-on" : ""}`}
|
||||
aria-pressed={liveSync}
|
||||
title={t("topology.liveSyncHint")}
|
||||
aria-label={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
||||
onClick={() => setLiveSync((v) => !v)}
|
||||
>
|
||||
<span className="topo-sidebar__rail-live-dot" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="topo-sidebar__section">
|
||||
|
|
@ -2167,6 +2222,17 @@ export function TopologyPage() {
|
|||
<SidebarFoldIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="topo-sidebar__live">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
|
||||
aria-pressed={liveSync}
|
||||
title={t("topology.liveSyncHint")}
|
||||
onClick={() => setLiveSync((v) => !v)}
|
||||
>
|
||||
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
||||
</button>
|
||||
</div>
|
||||
{treeSearchOpen && treeNeQuery.trim() ? (
|
||||
<div className="topo-tree-search__panel" role="listbox">
|
||||
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? (
|
||||
|
|
@ -2905,6 +2971,15 @@ export function TopologyPage() {
|
|||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
|
||||
aria-pressed={liveSync}
|
||||
title={t("topology.liveSyncHint")}
|
||||
onClick={() => setLiveSync((v) => !v)}
|
||||
>
|
||||
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
||||
</button>
|
||||
</div>
|
||||
{!activeRegion ? (
|
||||
regions.length === 0 ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue