diff --git a/.cursor/skills/netx-topology/SKILL.md b/.cursor/skills/netx-topology/SKILL.md new file mode 100644 index 0000000..7f01755 --- /dev/null +++ b/.cursor/skills/netx-topology/SKILL.md @@ -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 时连环盲写。 diff --git a/README.md b/README.md index 36963c7..2e27e94 100644 --- a/README.md +++ b/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 diff --git a/docs/MCP.md b/docs/MCP.md index 583967a..1f68fae 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -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__`。 +拓扑 Fabric / 画布工具已拆到 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**(含原 `queryTopologyEdges`)。oclaw 中名称带前缀:`mcp__netx__`。 --- diff --git a/docs/MCP_TOPOLOGY.md b/docs/MCP_TOPOLOGY.md new file mode 100644 index 0000000..9b0f8fb --- /dev/null +++ b/docs/MCP_TOPOLOGY.md @@ -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 +git pull +pip install -e ./packages/netx-topology-mcp +``` + +然后重启 MCP 宿主,并 Sync Tools。 diff --git a/mcp.json b/mcp.json index f776735..c8c6b28 100644 --- a/mcp.json +++ b/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" + } } } } diff --git a/mcp_topology_install_payload.json b/mcp_topology_install_payload.json new file mode 100644 index 0000000..ff02b91 --- /dev/null +++ b/mcp_topology_install_payload.json @@ -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 +} diff --git a/packages/netx-mcp/README.md b/packages/netx-mcp/README.md index 69740a9..dbb7e42 100644 --- a/packages/netx-mcp/README.md +++ b/packages/netx-mcp/README.md @@ -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))。 ## 兼容 diff --git a/packages/netx-mcp/src/netx_mcp/http_tools.py b/packages/netx-mcp/src/netx_mcp/http_tools.py index ed1d62a..dd9d457 100644 --- a/packages/netx-mcp/src/netx_mcp/http_tools.py +++ b/packages/netx-mcp/src/netx_mcp/http_tools.py @@ -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", } diff --git a/packages/netx-mcp/tests/test_mcp_http.py b/packages/netx-mcp/tests/test_mcp_http.py index ae7729c..4d26df8 100644 --- a/packages/netx-mcp/tests/test_mcp_http.py +++ b/packages/netx-mcp/tests/test_mcp_http.py @@ -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) diff --git a/packages/netx-topology-mcp/README.md b/packages/netx-topology-mcp/README.md new file mode 100644 index 0000000..93fac0c --- /dev/null +++ b/packages/netx-topology-mcp/README.md @@ -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 节点。 diff --git a/packages/netx-topology-mcp/mcp.json b/packages/netx-topology-mcp/mcp.json new file mode 100644 index 0000000..8b64ad7 --- /dev/null +++ b/packages/netx-topology-mcp/mcp.json @@ -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" + } + } + } +} diff --git a/packages/netx-topology-mcp/pyproject.toml b/packages/netx-topology-mcp/pyproject.toml new file mode 100644 index 0000000..1047996 --- /dev/null +++ b/packages/netx-topology-mcp/pyproject.toml @@ -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"] diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py b/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py new file mode 100644 index 0000000..4011e88 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py @@ -0,0 +1,3 @@ +"""netx topology MCP — canvas / fabric tools for drawing topology maps.""" + +__version__ = "0.1.0" diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/__main__.py b/packages/netx-topology-mcp/src/netx_topology_mcp/__main__.py new file mode 100644 index 0000000..2a503ae --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/__main__.py @@ -0,0 +1,4 @@ +from netx_topology_mcp.server import main + +if __name__ == "__main__": + main() diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py b/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py new file mode 100644 index 0000000..d24e955 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py @@ -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) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py new file mode 100644 index 0000000..8a7c10b --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py @@ -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 {}))) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/server.py b/packages/netx-topology-mcp/src/netx_topology_mcp/server.py new file mode 100644 index 0000000..98d3802 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/server.py @@ -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() diff --git a/packages/netx-topology-mcp/tests/test_mcp_topology.py b/packages/netx-topology-mcp/tests/test_mcp_topology.py new file mode 100644 index 0000000..59b088a --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_mcp_topology.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 150cecd..2cb74cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = ["."] diff --git a/tests/test_auth.py b/tests/test_auth.py index 96722fe..8f58b4c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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() diff --git a/web/public/login-earth-night.webp b/web/public/login-earth-night.webp new file mode 100644 index 0000000..58e36ed Binary files /dev/null and b/web/public/login-earth-night.webp differ diff --git a/web/public/login-space.webp b/web/public/login-space.webp new file mode 100644 index 0000000..54f3bc1 Binary files /dev/null and b/web/public/login-space.webp differ diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index a9f627f..179f6c7 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 05e3845..6b3c42c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "显示", diff --git a/web/src/index.css b/web/src/index.css index 351593c..05717a8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -5,8 +5,8 @@ body { margin: 0; font-family: Inter, "Segoe UI", Arial, sans-serif; - background: #eef2f7; - color: #1f2937; + background: #e8eef6; + color: #1e293b; } #root { @@ -14,6 +14,21 @@ body { } .app--shell { + /* Shared NetX chrome tokens — light ops shell + login-aligned blues */ + --nm-accent: #3b82f6; + --nm-accent-deep: #2563eb; + --nm-header: #0f1b2d; + --nm-chrome: #e8eef6; + --nm-nav: #e4eaf2; + --nm-nav-border: #c0cad6; + --nm-nav-hover: #d7e0eb; + --nm-nav-active: #dbeafe; + --nm-nav-ink: #0f1b2d; + --nm-nav-muted: #64748b; + --nm-panel-border: #c8d2de; + --nm-brand: #0f1b2d; + --nm-text: #1e293b; + --nm-muted: #64748b; display: flex; flex-direction: column; min-height: 100vh; @@ -25,7 +40,7 @@ body { justify-content: space-between; height: 48px; padding: 0 20px; - background: #0f2744; + background: var(--nm-header, #0f1b2d); border-bottom: 1px solid rgba(255, 255, 255, 0.08); box-shadow: none; } @@ -74,7 +89,7 @@ body { .app-brand__apps:hover, .app-brand__apps:focus-visible { - background: rgba(255, 255, 255, 0.1); + background: rgba(59, 130, 246, 0.22); color: #fff; outline: none; } @@ -111,8 +126,8 @@ body { .app-main { flex: 1; padding: 20px 28px 32px; - background: #eef2f7; - color: #1f2937; + background: var(--nm-chrome, #e8eef6); + color: var(--nm-text, #1e293b); } .workbench { @@ -136,7 +151,7 @@ body { margin: 0; font-size: 18px; font-weight: 700; - color: #0f2744; + color: var(--nm-brand, #0f1b2d); letter-spacing: 0.02em; } @@ -170,7 +185,7 @@ body { } .wb-section--monitoring .wb-section__title::before { - background: #1565c0; + background: var(--nm-accent, #3b82f6); } .wb-section--operations .wb-section__title::before { @@ -273,7 +288,7 @@ body { .wb-card__icon--blue { background: #e3f2fd; - color: #1565c0; + color: var(--nm-accent, #3b82f6); } .wb-card__icon--green { @@ -295,7 +310,7 @@ body { min-width: 0; font-size: 13px; font-weight: 650; - color: #0f2744; + color: var(--nm-brand, #0f1b2d); line-height: 1.25; white-space: nowrap; padding-right: 2px; @@ -322,7 +337,7 @@ body { .header-menu__item--active-light { background: #e3f2fd; - color: #1565c0; + color: var(--nm-accent, #3b82f6); } .header-menu { @@ -505,7 +520,7 @@ button { } a { - color: #1565c0; + color: var(--nm-accent, #3b82f6); text-decoration: none; } @@ -620,7 +635,7 @@ pre { .link-btn { border: none; background: transparent; - color: #1565c0; + color: var(--nm-accent, #3b82f6); padding: 0; cursor: pointer; } @@ -758,8 +773,8 @@ pre { .app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:hover, .app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:focus-visible { - border-color: rgba(255, 255, 255, 0.28); - background: rgba(255, 255, 255, 0.14); + border-color: rgba(59, 130, 246, 0.55); + background: rgba(59, 130, 246, 0.22); color: #fff; outline: none; } @@ -799,11 +814,23 @@ pre { .app-brand .header-menu__trigger.header-menu__trigger--on-brand:hover, .app-brand .header-menu__trigger.header-menu__trigger--on-brand:focus-visible { - background: rgba(255, 255, 255, 0.14); - border-color: rgba(255, 255, 255, 0.28); + background: rgba(59, 130, 246, 0.22); + border-color: rgba(59, 130, 246, 0.55); color: #fff; } +.app-brand .conn-pill.conn-pill--on-brand.conn-pill--up { + border-color: rgba(59, 130, 246, 0.35); +} + +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:hover, +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:focus-visible { + border-color: rgba(59, 130, 246, 0.55); + background: rgba(59, 130, 246, 0.22); + color: #fff; + outline: none; +} + .toast { position: fixed; right: 20px; @@ -858,7 +885,7 @@ pre { .app-main .inline-spinner { border-color: rgba(21, 101, 192, 0.2); - border-top-color: #1565c0; + border-top-color: var(--nm-accent, #3b82f6); } .skeleton-line { @@ -925,7 +952,7 @@ pre { border: none; background: transparent; box-shadow: none; - color: #1565c0; + color: var(--nm-accent, #3b82f6); outline: none; } @@ -1143,7 +1170,7 @@ pre { .ne-tag-card__title { font-size: 13px; font-weight: 650; - color: #0f2744; + color: var(--nm-brand, #0f1b2d); margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; @@ -1162,14 +1189,10 @@ pre { gap: 6px; } -/* Managed NE / UME / System pages — classic slate chrome aligned with network management */ +/* Managed NE / UME / System pages — light content on shared chrome tokens */ .ne-page, .ume-page, .system-page { - --nm-brand: #0f2744; - --nm-accent: #1a56a8; - --nm-chrome: #d5dde8; - --nm-muted: #5b6b7c; margin: -20px -28px -32px; padding: 16px 18px 24px; min-height: calc(100vh - 48px); @@ -1183,8 +1206,8 @@ pre { .ume-page > .cards > .card, .system-page > .panel { padding: 16px 18px 18px; - border-color: #c8d2de; - box-shadow: 0 1px 2px rgba(15, 39, 68, 0.06); + border-color: var(--nm-panel-border); + box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06); } .ume-page > .cards { @@ -1200,7 +1223,7 @@ pre { .system-page .panel__toolbar { margin-bottom: 14px; padding-bottom: 12px; - border-bottom: 1px solid #d7dee8; + border-bottom: 1px solid var(--nm-panel-border, #c8d2de); } .ne-page .panel__toolbar h2, @@ -1228,13 +1251,13 @@ pre { .ume-page .card > .panel__toolbar { margin-bottom: 14px; padding-bottom: 12px; - border-bottom: 1px solid #d7dee8; + border-bottom: 1px solid var(--nm-panel-border, #c8d2de); } .ume-page .card__section-title { margin: 16px 0 10px; padding-top: 12px; - border-top: 1px solid #d7dee8; + border-top: 1px solid var(--nm-panel-border, #c8d2de); font-size: 14px; font-weight: 650; color: var(--nm-brand); @@ -1349,6 +1372,70 @@ pre { color: var(--nm-brand); } +.system-page .token-create { + display: flex; + flex-direction: column; + gap: 12px; +} + +.system-page .token-scopes { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + border: 1px solid #cdd6e2; + border-radius: 8px; + background: #f8fafc; +} + +.system-page .token-scopes__head { + display: flex; + flex-direction: column; + gap: 4px; +} + +.system-page .token-scopes__presets { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.system-page .token-scopes__inherit { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #334155; +} + +.system-page .token-scopes__grid { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.system-page .token-scopes__grid.is-disabled { + opacity: 0.45; +} + +.system-page .token-scopes__grid label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #334155; + white-space: nowrap; +} + +.system-page .token-scopes-cell { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: #475569; +} + .ume-page .pt-list-actions, .ume-page .pt-list-table .btn-row { gap: 6px; @@ -1757,7 +1844,7 @@ pre { } .panel__hint--live { - color: #1565c0; + color: var(--nm-accent, #3b82f6); } .collect-commands { @@ -1945,17 +2032,8 @@ pre { overflow-y: auto !important; } -/* WebCRT — classic slate chrome aligned with NetX shell */ +/* WebCRT — light sidebar + main (shared chrome tokens) */ .webcrt-shell { - --nm-brand: #0f2744; - --nm-accent: #1a56a8; - --nm-chrome: #d5dde8; - --nm-nav: #e4eaf2; - --nm-nav-border: #c0cad6; - --nm-nav-hover: #d7e0eb; - --nm-nav-active: #d6e4f5; - --nm-muted: #5b6b7c; - --nm-text: #243447; display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 48px); @@ -1975,7 +2053,8 @@ pre { min-height: 0; overflow: hidden; height: 100%; - box-shadow: inset -1px 0 0 rgba(15, 39, 68, 0.04); + color: var(--nm-text); + box-shadow: inset -1px 0 0 rgba(15, 27, 45, 0.04); } .webcrt-sidebar__header { @@ -2352,9 +2431,14 @@ pre { border: 1px solid #b8c4d2; border-radius: 6px; background: #fff; + color: var(--nm-text); font-size: 12px; } +.webcrt-sidebar__search input::placeholder { + color: var(--nm-muted); +} + .webcrt-sidebar__chrome .webcrt-source-tabs { margin: 0; } @@ -2635,7 +2719,7 @@ pre { height: 100%; overflow: hidden; background: #fff; - border-left: 1px solid var(--nm-nav-border); + border-left: 1px solid var(--nm-panel-border); } .webcrt-tabs { @@ -3530,17 +3614,8 @@ pre { } } -/* ---- Topology — classic slate chrome aligned with NetX shell ---- */ +/* ---- Topology — light sidebar + canvas (shared chrome tokens) ---- */ .topo-page { - --nm-brand: #0f2744; - --nm-accent: #1a56a8; - --nm-chrome: #d5dde8; - --nm-nav: #e4eaf2; - --nm-nav-border: #c0cad6; - --nm-nav-hover: #d7e0eb; - --nm-nav-active: #d6e4f5; - --nm-muted: #5b6b7c; - --nm-text: #243447; display: grid; grid-template-columns: 260px 1fr; gap: 12px; @@ -3566,7 +3641,8 @@ pre { border-radius: 8px; padding: 10px; overflow: hidden; - box-shadow: 0 1px 2px rgba(15, 39, 68, 0.06); + color: var(--nm-text); + box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06); } .topo-page.is-sidebar-collapsed .topo-sidebar { @@ -3574,8 +3650,15 @@ pre { align-items: stretch; } -.topo-sidebar__rail { +.topo-sidebar__rail-wrap { + display: flex; + flex-direction: column; flex: 1; + min-height: 0; + gap: 4px; +} + +.topo-sidebar__rail { display: flex; flex-direction: column; align-items: center; @@ -3590,6 +3673,33 @@ pre { border-radius: 6px; } +.topo-sidebar__rail-wrap > .topo-sidebar__rail:first-child { + flex: 1; +} + +.topo-sidebar__rail--live { + flex: 0 0 auto; + padding: 10px 0; +} + +.topo-sidebar__rail--live.is-on { + color: #1d4ed8; + background: #eff6ff; +} + +.topo-sidebar__rail-live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + opacity: 0.45; +} + +.topo-sidebar__rail--live.is-on .topo-sidebar__rail-live-dot { + opacity: 1; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); +} + .topo-sidebar__rail .topo-sidebar__rail-icon { width: 28px; height: 28px; @@ -3658,8 +3768,8 @@ pre { .app-main .topo-sidebar__icon-btn:hover:not(:disabled), .topo-sidebar__icon-btn:hover:not(:disabled) { - color: var(--nm-brand, #0f2744); - background: rgba(15, 39, 68, 0.06) !important; + color: var(--nm-brand, #0f1b2d); + background: rgba(15, 27, 45, 0.06) !important; } .app-main .topo-sidebar__icon-btn:disabled, @@ -3680,6 +3790,17 @@ pre { min-width: 0; } +.topo-sidebar__live { + display: flex; + align-items: center; + margin-top: 2px; +} + +.topo-sidebar__live .btn { + width: 100%; + justify-content: center; +} + .topo-tree-search__bar > .input { flex: 1 1 auto; min-width: 0; @@ -4026,7 +4147,7 @@ pre { gap: 4px; } .topo-dir__toolbar button:hover { - background: rgba(26, 86, 168, 0.08); + background: rgba(59, 130, 246, 0.08); } .topo-dir__hint { margin: 0; @@ -4396,7 +4517,7 @@ pre { font-weight: 550; letter-spacing: -0.01em; line-height: 1.3; - color: var(--nm-brand, #0f2744); + color: var(--nm-brand, #0f1b2d); } .app-main .topo-breadcrumb__link, .topo-breadcrumb__link { @@ -4415,7 +4536,7 @@ pre { } .app-main .topo-breadcrumb__link:hover, .topo-breadcrumb__link:hover { - color: var(--nm-accent, #1a56a8) !important; + color: var(--nm-accent, #3b82f6) !important; background: transparent !important; text-decoration: underline; text-underline-offset: 2px; @@ -4432,7 +4553,7 @@ pre { border-radius: 0; font: inherit; font-weight: 700; - color: var(--nm-accent, #1a56a8); + color: var(--nm-accent, #3b82f6); background: transparent; box-shadow: none; } @@ -4689,7 +4810,7 @@ pre { .topo-classify__section h3 { margin: 0 0 10px; font-size: 14px; - color: var(--nm-brand, #0f2744); + color: var(--nm-brand, #0f1b2d); } .topo-classify__section-head { display: flex; @@ -4742,7 +4863,7 @@ pre { padding-left: 0; } .topo-classify__view-links a { - color: var(--nm-accent, #1a56a8); + color: var(--nm-accent, #3b82f6); } .topo-region-list { @@ -4891,14 +5012,14 @@ pre { height: 24px; min-height: 24px; border-radius: 5px; - color: #475569; + color: #64748b; background: transparent !important; border: 0 !important; } .app-main .topo-region-list .topo-map-list__icon:hover:not(:disabled), .topo-region-list .topo-map-list__icon:hover:not(:disabled) { - background: rgba(15, 39, 68, 0.12) !important; + background: rgba(15, 27, 45, 0.08) !important; color: var(--nm-brand); } @@ -4918,7 +5039,7 @@ pre { white-space: nowrap; font-size: 13px; font-weight: 650; - color: var(--nm-brand, #0f2744); + color: var(--nm-brand, #0f1b2d); } .topo-region-list__maps .topo-map-list__title { @@ -4934,27 +5055,27 @@ pre { font-size: 11px; font-weight: 600; letter-spacing: 0.02em; - color: #64748b; + color: var(--nm-muted); background: transparent; box-shadow: none; font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; } .topo-region-list__maps .topo-map-list__count { - color: #64748b; + color: var(--nm-muted); background: transparent; } .topo-region-list__block.is-active .topo-map-list__count, .topo-region-list__maps > li.is-active .topo-map-list__count { background: transparent; - color: #0a274f; + color: #0f1b2d; } .topo-region-list__block.is-hot:not(.is-active) .topo-map-list__count, .topo-region-list__maps > li.is-hot:not(.is-active) .topo-map-list__count { background: transparent; - color: #1a56a8; + color: var(--nm-accent); } .topo-region-list__block.is-active .topo-map-list__title, @@ -5086,7 +5207,7 @@ pre { } .topo-map-list li.is-active .topo-map-list__actions { - border-left-color: #9bb6d9; + border-left-color: #93b4e0; } .topo-map-list__icon { @@ -5102,7 +5223,7 @@ pre { } .topo-map-list__icon:hover:not(:disabled) { - background: var(--nm-nav-hover); + background: #e8eef8; color: var(--nm-brand); } @@ -5127,7 +5248,7 @@ pre { display: block; font-weight: 650; font-size: 12px; - color: var(--nm-brand, #0f2744); + color: var(--nm-brand, #0f1b2d); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -5137,7 +5258,7 @@ pre { .topo-palette__meta { display: block; font-size: 11px; - color: var(--nm-muted, #5b6b7c); + color: var(--nm-muted, #64748b); font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; overflow: hidden; text-overflow: ellipsis; @@ -5150,10 +5271,10 @@ pre { min-width: 0; min-height: 0; background: #fff; - border: 1px solid var(--nm-nav-border, #c0cad6); + border: 1px solid var(--nm-panel-border, #c8d2de); border-radius: 8px; overflow: hidden; - box-shadow: 0 1px 2px rgba(15, 39, 68, 0.06); + box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06); } .topo-toolbar { @@ -5254,7 +5375,7 @@ pre { .topo-find-suggest__name { font-size: 12px; font-weight: 650; - color: #0f2744; + color: var(--nm-brand, #0f1b2d); } .topo-find-suggest__meta { @@ -5301,7 +5422,7 @@ pre { white-space: nowrap; font-size: 14px; font-weight: 650; - color: var(--nm-brand, #0f2744); + color: var(--nm-brand, #0f1b2d); letter-spacing: -0.01em; } @@ -5368,11 +5489,11 @@ pre { .topo-tools__btn:hover { background: #fff; - color: var(--nm-brand, #0f2744); + color: var(--nm-brand, #0f1b2d); } .topo-tools__btn.is-active { - background: var(--nm-accent, #1a56a8); + background: var(--nm-accent, #3b82f6); color: #f8fafc; } @@ -5400,7 +5521,7 @@ pre { border: 1px solid #b8c4d2; border-radius: 6px; background: #fff; - color: #0f2744; + color: var(--nm-brand, #0f1b2d); font-size: 12px; line-height: 1.2; padding: 0 8px; @@ -5417,7 +5538,7 @@ pre { border: 1px solid #b8c4d2; border-radius: 6px; background: #fff; - color: #0f2744; + color: var(--nm-brand, #0f1b2d); font-size: 12px; font-weight: 600; line-height: 1.2; @@ -6246,9 +6367,14 @@ pre { border: 1px solid #b8c4d2; border-radius: 6px; background: #fff; + color: var(--nm-text); font-size: 12px; } +.topo-sidebar .input::placeholder { + color: var(--nm-muted); +} + .topo-sidebar .btn.btn--sm { display: inline-flex; align-items: center; @@ -6258,6 +6384,8 @@ pre { line-height: 1; border-radius: 6px; border-color: #b8c4d2; + background: #fff; + color: var(--nm-brand); font-size: 12px; } @@ -6467,149 +6595,258 @@ pre { } .login-page { - --login-ink: #0f2744; - --login-accent: #0e7490; - --login-accent-deep: #155e75; - --login-panel: rgba(255, 255, 255, 0.88); - --login-line: rgba(15, 39, 68, 0.12); - --login-muted: #5b6b7c; - --login-text: #243447; - --login-danger: #b42318; + --login-accent: #3b82f6; + --login-accent-deep: #2563eb; + --login-ink: rgba(255, 255, 255, 0.92); + --login-muted: rgba(255, 255, 255, 0.55); + --login-field: rgba(255, 255, 255, 0.08); + --login-field-border: rgba(255, 255, 255, 0.14); + --login-danger: #fda29b; position: relative; isolation: isolate; min-height: 100vh; + overflow: hidden; display: flex; align-items: center; justify-content: center; - padding: 28px 20px; - overflow: hidden; - background: - linear-gradient(160deg, #e8eef5 0%, #f3f7fa 42%, #e4eef2 100%); + background: #00050a; font-family: "IBM Plex Sans", "Segoe UI", sans-serif; - color: var(--login-text); + color: var(--login-ink); } -.login-page__atmosphere { +.login-page__space { position: absolute; inset: 0; - z-index: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + background: + linear-gradient(rgba(0, 0, 0, 0.58), rgba(0, 0, 0, 0.42)), + url("/login-space.webp") center / cover no-repeat; +} + +.login-page__space::before { + content: ""; + position: absolute; + inset: 0; + z-index: 2; pointer-events: none; - overflow: hidden; + background: + linear-gradient(transparent 0%, rgba(0, 0, 0, 0.32) 100%), + linear-gradient(transparent 50%, rgba(0, 0, 0, 0.68) 100%); } -.login-page__orb { +.login-planet { position: absolute; + z-index: 1; + width: 520px; + height: 520px; border-radius: 50%; - filter: blur(8px); - opacity: 0.55; - animation: login-orb-drift 18s ease-in-out infinite alternate; + transform: scale(2.5) translateY(38%) rotate(12.2deg); + animation: login-planet-enter 1.8s cubic-bezier(0.76, -0.46, 0.37, 0.99) both; } -.login-page__orb--a { - width: min(52vw, 520px); - height: min(52vw, 520px); - top: -12%; - left: -8%; - background: radial-gradient(circle at 35% 35%, #7dd3fc 0%, rgba(14, 116, 144, 0.25) 42%, transparent 70%); -} - -.login-page__orb--b { - width: min(48vw, 460px); - height: min(48vw, 460px); - right: -10%; - bottom: -18%; - background: radial-gradient(circle at 40% 40%, #93c5fd 0%, rgba(15, 39, 68, 0.18) 48%, transparent 72%); - animation-delay: -6s; - animation-duration: 22s; -} - -.login-page__grid { +.login-planet__railway { position: absolute; - inset: -20%; - opacity: 0.28; - background-image: - linear-gradient(rgba(15, 39, 68, 0.06) 1px, transparent 1px), - linear-gradient(90deg, rgba(15, 39, 68, 0.06) 1px, transparent 1px); - background-size: 48px 48px; - mask-image: radial-gradient(ellipse at 50% 45%, #000 20%, transparent 72%); - animation: login-grid-pan 28s linear infinite; + top: -40px; + left: -40px; + width: calc(100% + 80px); + height: calc(100% + 80px); + border-radius: 50%; + border: 0.5px dashed rgba(255, 255, 255, 0.48); + animation: login-railway-rotate 40s linear infinite; + pointer-events: none; +} + +.login-planet__earth { + position: relative; + width: 100%; + height: 100%; + border-radius: 50%; + overflow: hidden; + box-shadow: + 0 0 8px rgba(150, 186, 255, 0.48), + 0 0 60px 12px rgba(150, 186, 255, 0.32); + transform-origin: center center; +} + +.login-planet__earth::after { + content: ""; + position: absolute; + z-index: 2; + inset: 0; + border-radius: 50%; + pointer-events: none; + box-shadow: + inset 0 0 8px 4px rgba(59, 104, 196, 0.98), + inset -24px 0 12px 2px rgba(59, 104, 196, 0.08), + inset 0 0 48px 48px rgba(59, 104, 196, 0.28), + inset -48px 0 24px 48px rgba(59, 104, 196, 0.08); + animation: login-earth-inner 1.8s ease-in-out 0.9s both; +} + +.login-planet__textures { + position: absolute; + top: 0; + left: 0; + width: 200%; + height: 100%; + background-color: rgba(0, 18, 77, 0.18); + background-image: url("/login-earth-night.webp"); + background-size: 50% 100%; + background-repeat: repeat-x; + filter: brightness(2.4) contrast(1.2); + animation: login-earth-spin 60s linear infinite; + will-change: transform; + transform: translate3d(0, 0, 0); +} + +.login-page__content { + position: relative; + z-index: 3; + width: min(400px, calc(100% - 40px)); + padding: 24px 0; } .login-card { - position: relative; - z-index: 1; - width: min(400px, 100%); - padding: 36px 32px 28px; - background: var(--login-panel); - border: 1px solid var(--login-line); - border-radius: 16px; - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); + width: 100%; display: flex; flex-direction: column; gap: 14px; + padding: 20px 20px 22px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(8, 16, 32, 0.22); + backdrop-filter: blur(8px) saturate(1.08); + -webkit-backdrop-filter: blur(8px) saturate(1.08); + box-shadow: + 0 8px 28px rgba(0, 0, 0, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.05); animation: login-card-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both; } -.login-card__brand { +.login-card__head { + display: flex; + align-items: baseline; + justify-content: flex-start; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 2px; +} + +.login-card__brand, +.login-card__title { + margin: 0; font-family: Sora, "IBM Plex Sans", sans-serif; - font-size: clamp(2.4rem, 6vw, 3rem); + font-size: clamp(1.55rem, 4vw, 1.85rem); font-weight: 700; - line-height: 1; - letter-spacing: 0.08em; - color: var(--login-ink); + line-height: 1.15; + color: rgba(226, 232, 240, 0.88); +} + +.login-card__brand { + letter-spacing: 0.1em; + text-shadow: 0 0 22px rgba(59, 130, 246, 0.28); animation: login-brand-in 700ms cubic-bezier(0.22, 1, 0.36, 1) both; } .login-card__title { - margin: 0 0 4px; - font-family: Sora, "IBM Plex Sans", sans-serif; - font-size: 1.05rem; - font-weight: 600; - letter-spacing: 0.01em; - color: var(--login-muted); + font-weight: 650; + letter-spacing: 0.02em; + color: rgba(203, 213, 225, 0.9); } .login-card__hint { - margin: -4px 0 2px; + margin: -4px 0 4px; color: var(--login-muted); font-size: 13px; line-height: 1.45; } -.login-card__label { - display: flex; - flex-direction: column; - gap: 7px; - font-size: 13px; - font-weight: 500; - color: var(--login-ink); +.login-card__sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } -.login-card__label input { +.login-card__label { + position: relative; + display: block; +} + +.login-card__label input, +.login-card__password input { + width: 100%; height: 44px; padding: 0 14px; - border: 1px solid var(--login-line); + border: 1px solid var(--login-field-border); border-radius: 10px; - background: rgba(255, 255, 255, 0.92); - color: var(--login-ink); + background: var(--login-field); + color: #fff; font: inherit; font-size: 15px; + box-sizing: border-box; transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; } -.login-card__label input:hover:not(:disabled) { - border-color: rgba(14, 116, 144, 0.35); +.login-card__label input::placeholder, +.login-card__password input::placeholder { + color: rgba(255, 255, 255, 0.42); } -.login-card__label input:focus { +.login-card__password { + position: relative; + display: block; +} + +.login-card__password input { + padding-right: 44px; +} + +.login-card__eye { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + width: 34px; + height: 34px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: rgba(255, 255, 255, 0.55); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.login-card__eye:hover { + color: #93c5fd; +} + +.login-card__eye svg { + display: block; + pointer-events: none; +} + +.login-card__label input:focus, +.login-card__password input:focus { outline: none; - border-color: var(--login-accent); - box-shadow: 0 0 0 3px rgba(14, 116, 144, 0.18); - background: #fff; + border-color: rgba(59, 130, 246, 0.7); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.22); + background: rgba(255, 255, 255, 0.1); } -.login-card__label input:disabled { +.login-card__label input:disabled, +.login-card__password input:disabled { opacity: 0.65; cursor: not-allowed; } @@ -6620,28 +6857,31 @@ pre { line-height: 1.4; padding: 8px 10px; border-radius: 8px; - background: rgba(180, 35, 24, 0.06); - border: 1px solid rgba(180, 35, 24, 0.14); + background: rgba(180, 35, 24, 0.22); + border: 1px solid rgba(253, 162, 155, 0.28); animation: login-error-in 220ms ease both; } .login-card__submit { - margin-top: 6px; + margin-top: 8px; height: 44px; border: 0; - border-radius: 10px; - background: linear-gradient(180deg, #0e8aa8 0%, var(--login-accent-deep) 100%); + border-radius: 9999px; + background: linear-gradient(to right, #3b82f6, #2563eb, #2563eb); color: #fff; font-family: Sora, "IBM Plex Sans", sans-serif; font-size: 15px; font-weight: 600; - letter-spacing: 0.02em; + letter-spacing: 0.04em; + box-shadow: + 0 8px 15px -3px rgba(59, 130, 246, 0.5), + 0 4px 6px -4px rgba(59, 130, 246, 0.5); cursor: pointer; transition: transform 140ms ease, filter 140ms ease, opacity 140ms ease; } .login-card__submit:hover:not(:disabled) { - filter: brightness(1.05); + filter: brightness(1.06); transform: translateY(-1px); } @@ -6655,15 +6895,66 @@ pre { cursor: not-allowed; } +.login-card__submit--ghost, .login-card__submit + .login-card__submit { margin-top: 0; - background: #64748b; + background: transparent; + border: 1px solid rgba(59, 130, 246, 0.7); + color: #93c5fd; + box-shadow: none; +} + +.login-card__submit--ghost:hover:not(:disabled), +.login-card__submit + .login-card__submit:hover:not(:disabled) { + background: rgba(59, 130, 246, 0.12); + filter: none; +} + +@keyframes login-planet-enter { + 0% { + transform: scale(1) translateY(0) rotate(-12.2deg); + } +} + +@keyframes login-railway-rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes login-earth-spin { + from { + transform: translate3d(0, 0, 0); + } + to { + transform: translate3d(-50%, 0, 0); + } +} + +@keyframes login-earth-inner { + 0% { + box-shadow: + inset 0 0 8px 4px rgba(59, 104, 196, 0.98), + inset -24px 0 12px 2px rgba(59, 104, 196, 0.08), + inset 0 0 48px 48px rgba(59, 104, 196, 0.28), + inset -48px 0 24px 48px rgba(59, 104, 196, 0.08); + } + 100% { + box-shadow: + inset 0 0 8px 4px rgba(59, 104, 196, 0.98), + inset 24px 0 12px 2px rgba(59, 104, 196, 0.58), + inset 0 8px 48px 48px rgba(59, 104, 196, 0.28), + inset 48px 0 24px 48px rgba(59, 104, 196, 0.28); + } } @keyframes login-card-in { from { opacity: 0; - transform: translateY(18px); + transform: translateY(12px); } to { opacity: 1; @@ -6674,34 +6965,16 @@ pre { @keyframes login-brand-in { from { opacity: 0; - letter-spacing: 0.22em; + letter-spacing: 0.24em; transform: translateY(8px); } to { opacity: 1; - letter-spacing: 0.08em; + letter-spacing: 0.12em; transform: translateY(0); } } -@keyframes login-orb-drift { - from { - transform: translate3d(0, 0, 0) scale(1); - } - to { - transform: translate3d(3%, 4%, 0) scale(1.06); - } -} - -@keyframes login-grid-pan { - from { - transform: translate3d(0, 0, 0); - } - to { - transform: translate3d(-48px, -24px, 0); - } -} - @keyframes login-error-in { from { opacity: 0; @@ -6714,42 +6987,43 @@ pre { } @media (prefers-reduced-motion: reduce) { - .login-page__orb, - .login-page__grid, + /* Keep ambient earth / orbit motion; only drop entrance flourishes. */ + .login-planet, + .login-planet__earth::after, .login-card, .login-card__brand, .login-card__error { animation: none !important; } + .login-planet { + transform: scale(2.5) translateY(38%) rotate(12.2deg); + } + .login-card__submit:hover:not(:disabled) { transform: none; } } @media (max-width: 480px) { - .login-card { - padding: 28px 20px 22px; + .login-planet { + transform: scale(1.5) translateY(28%) rotate(12.2deg); } - .login-card__brand { - font-size: 2.2rem; + .login-card { + padding: 18px 16px 20px; + } + + .login-card__brand, + .login-card__title { + font-size: 1.4rem; } } + /* —— Network Management shell (left nav + main) —— - Classic ops palette aligned with brand header #0f2744: - cooler slate chrome, white content cards, blue active accent. */ + Light ops shell + login-aligned accent blues. */ .network-shell { - --nm-brand: #0f2744; - --nm-accent: #1a56a8; - --nm-chrome: #d5dde8; - --nm-nav: #e4eaf2; - --nm-nav-border: #c0cad6; - --nm-nav-hover: #d7e0eb; - --nm-nav-active: #d6e4f5; - --nm-text: #243447; - --nm-muted: #5b6b7c; display: flex; height: calc(100vh - 48px); min-height: 480px; @@ -6766,7 +7040,8 @@ pre { background: var(--nm-nav); border-right: 1px solid var(--nm-nav-border); min-height: 0; - box-shadow: inset -1px 0 0 rgba(15, 39, 68, 0.04); + color: var(--nm-text); + box-shadow: inset -1px 0 0 rgba(15, 27, 45, 0.04); } .network-nav__scroll { @@ -6885,8 +7160,8 @@ pre { .network-main > .panel { padding: 16px 18px 18px; - border-color: #c8d2de; - box-shadow: 0 1px 2px rgba(15, 39, 68, 0.06); + border-color: var(--nm-panel-border); + box-shadow: 0 1px 2px rgba(15, 27, 45, 0.06); } .network-main .panel__toolbar { diff --git a/web/src/pages/ApiTokensPage.tsx b/web/src/pages/ApiTokensPage.tsx index f12cf3c..2d3b46b 100644 --- a/web/src/pages/ApiTokensPage.tsx +++ b/web/src/pages/ApiTokensPage.tsx @@ -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([...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 (
@@ -113,43 +184,91 @@ export function ApiTokensPage() {

{t("auth.apiKeysHint")}

-
- setName(e.target.value)} - required - /> - - {isAdmin ? ( + +
+ setName(e.target.value)} + required + /> - ) : null} - + {isAdmin ? ( + + ) : null} + +
+ +
+
+ {t("auth.scopesTitle")} + + {t("auth.scopesHint")} + +
+
+ + +
+ +
+ {ALL_SCOPE_KEYS.filter((s) => availableScopes.includes(s)).map((scope) => ( + + ))} + {!availableScopes.length ? ( + {t("auth.scopesNoneAvailable")} + ) : null} +
+
{createdPlain ? ( @@ -177,6 +296,7 @@ export function ApiTokensPage() { {t("auth.tokenName")} {t("auth.tokenOwner")} + {t("auth.scopesCol")} {t("auth.colTime")} {t("auth.expiresAt")} {t("auth.lastUsed")} @@ -189,6 +309,9 @@ export function ApiTokensPage() { {row.name} {row.username || row.user_id} + + {formatScopes(row.scopes)} + {row.created_at ? formatSystemTime(row.created_at) : t("common.empty")} diff --git a/web/src/pages/ForceChangePasswordPage.tsx b/web/src/pages/ForceChangePasswordPage.tsx index 3581123..63d06b1 100644 --- a/web/src/pages/ForceChangePasswordPage.tsx +++ b/web/src/pages/ForceChangePasswordPage.tsx @@ -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 ( -
- +
void onSubmit(e)}> -
- NETX +
+

{t("auth.forceChangeTitle")}

+
+ NETX +
-

{t("auth.forceChangeTitle")}

{t("auth.forceChangeHint", { user: user?.username || "admin" })}

- {error ?
{error}
: null} + {error ? ( +
+ {error} +
+ ) : null} -
+
); } diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 272a7df..3b416e9 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -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 ( -
- +
void onSubmit(e)}> -
- NETX +
+

{t("auth.loginTitle")}

+
+ NETX +
-

{t("auth.loginTitle")}

{error ? (
@@ -71,6 +103,6 @@ export function LoginPage() { {busy ? t("auth.loggingIn") : t("auth.login")} -
+ ); } diff --git a/web/src/pages/LoginShell.tsx b/web/src/pages/LoginShell.tsx new file mode 100644 index 0000000..f367700 --- /dev/null +++ b/web/src/pages/LoginShell.tsx @@ -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 ( +
+