diff --git a/CHANGELOG.md b/CHANGELOG.md index c0af970..46dc22d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.65.1 — 2026-08-12 + +- 管理后台现在可查看 Windows Node 的在线状态、运行环境、能力和最近心跳,并可禁用、重新启用或永久删除节点。 + ## 0.65.0 — 2026-08-12 - 新增 Windows Node:可在 Windows 本机通过托盘程序注册并连接 zcbot,为后续受控调用本机科研软件提供节点基础;管理后台可生成 10 分钟有效、仅可使用一次的注册码。 diff --git a/PROGRESS.md b/PROGRESS.md index 492d52f..7aa40b9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-12(Windows Node 客户端 + 管理端注册码,bump 0.65.0) +最后更新:2026-08-12(Windows Node 列表与生命周期管理,bump 0.65.1) --- @@ -23,6 +23,8 @@ ### 2026-08-12 +- **08-12 / 0.65.1 / Windows Node 列表与生命周期管理**:管理后台按 10 秒轮询展示节点名称/ID、在线状态、系统与桌面会话、版本、能力和最近心跳,并提供可恢复的禁用/启用与永久删除;禁用或删除在线节点先主动关闭 WebSocket,删除后的本机身份必须清除并重新注册。生产实测补齐 nginx 节点 WebSocket Upgrade 专用 location 与故障说明,避免注册成功后长连接被普通 HTTP location 当作 GET。相关后端、鉴权路由和管理前端 45 项 unittest、Python 编译、JavaScript 语法与 diff 检查通过;无 schema、migration 或依赖变化,未连接或写入生产 DB。 + - **08-12 / 0.65.0 / Windows Node 客户端 + 管理端注册码**:新增 .NET 10 WinForms 托盘节点,可通过界面或 CLI 使用一次性注册码完成注册,使用 DPAPI LocalMachine 加密 Node Token,并将配置 ACL 收紧至注册账号与 SYSTEM;支持登录后自启动、状态角标、配置窗、重连和身份清除。管理后台新增显式生成入口,可限定节点名称,展示 10 分钟过期时间并一键复制,注册码不写浏览器存储。后端注册、WebSocket 心跳、节点禁用与 0030 schema 已在前一基础提交落地;Windows Node、计算节点及管理前端相关 27 项 unittest、.NET build/format、JavaScript 语法与 diff 检查通过,未连接或迁移生产 DB。 - **08-12 / 0.64.3 / 同名上传保护 + 明确替换文件**:粘贴、拖拽与普通上传默认由服务端原子独占创建,同名按 `name (n).ext` 自动编号且并发请求不会互相覆盖;文件菜单新增「替换文件」,选择本地文件并二次确认后才以同目录临时文件原子替换指定目标。Python 冲突策略 4 项、Node 前端契约 2 项、JavaScript/Python 语法及 diff 检查通过;DB 路由回归已补,未配置显式测试库时安全跳过;无 schema、migration 或依赖变化,未连接生产 DB。 diff --git a/RUN.md b/RUN.md index 0d3cc0d..dd7e731 100644 --- a/RUN.md +++ b/RUN.md @@ -1048,7 +1048,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_" /opt - 管理员 `POST /v1/admin/compute-node-enrollments` 创建一次性注册码; - Node `POST /v1/compute/nodes/enroll` 注册并一次性取得 `node_id`、`node_token`; - Node 携带 `Authorization: Bearer ` 和 `X-Node-Id` 连接 `WS /v1/compute/nodes/connect`; -- 管理员 `GET /v1/admin/compute-nodes` 查看节点,`PATCH /v1/admin/compute-nodes/{node_id}` 启停节点。 +- 管理员 `GET /v1/admin/compute-nodes` 查看节点,`PATCH /v1/admin/compute-nodes/{node_id}` 启停节点,`DELETE /v1/admin/compute-nodes/{node_id}` 永久删除节点身份。 Node API 只能绑定受控内网地址并由安全组限制来源 IP。当前 HTTP/WS 链路不加密;跨安全域、公网或不可信终端接入前,必须先升级 HTTPS/WSS。多 Web 实例部署时,Node API 暂时固定路由到单一实例,直至 Connection Manager 增加跨实例 fencing。 @@ -1067,6 +1067,8 @@ windows-node/Zcbot.WindowsNode/bin/Debug/net10.0-windows/Zcbot.WindowsNode.exe 直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`。 +经 nginx 反代时,`/v1/compute/nodes/connect` 必须单独透传 WebSocket Upgrade/Connection 头并设置长连接超时,配置见 `deploy/nginx/zcbot.conf.example`。若注册成功后节点持续显示“连接中断,等待重连”,先用 WebSocket 握手检查该路径;返回普通 HTTP 404 通常表示请求落入了清空 `Connection` 头的默认 location。 + - **入口**:`main.py`(`web / db / probe / user`)→ `core/agent_builder.py::build_agent` - **核心**:`core/{agent_builder, loop, session, task, llm, memory, paths}.py` + `core/storage/{engine,models,utils}.py` + `db/migrations/` diff --git a/core/__init__.py b/core/__init__.py index 0bcdcc6..a976eef 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.65.0" +__version__ = "0.65.1" diff --git a/core/compute_nodes.py b/core/compute_nodes.py index 197cb46..6a25c1e 100644 --- a/core/compute_nodes.py +++ b/core/compute_nodes.py @@ -177,6 +177,16 @@ def set_node_disabled(node_id: UUID, disabled: bool) -> bool: return True +def delete_node(node_id: UUID) -> bool: + """撤销并物理删除节点身份;当前节点表没有任务历史外键。""" + with session_scope() as session: + node = session.get(ComputeNode, node_id) + if node is None: + return False + session.delete(node) + return True + + def list_nodes() -> list[dict]: with session_scope() as session: rows = ( diff --git a/deploy/nginx/zcbot.conf.example b/deploy/nginx/zcbot.conf.example index 58f77ca..07bc84e 100644 --- a/deploy/nginx/zcbot.conf.example +++ b/deploy/nginx/zcbot.conf.example @@ -58,6 +58,22 @@ server { proxy_send_timeout 300s; } + # ★ Windows Node 长连接:注册走普通 POST,注册后的节点控制通道走 WebSocket。 + # 必须独立于下面会清空 Connection 头的默认 location;15s 应用心跳保持链路活跃。 + location = /v1/compute/nodes/connect { + proxy_pass http://zcbot_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + location / { proxy_pass http://zcbot_backend; proxy_http_version 1.1; diff --git a/tests/test_compute_nodes.py b/tests/test_compute_nodes.py index 505b8a2..4c71d17 100644 --- a/tests/test_compute_nodes.py +++ b/tests/test_compute_nodes.py @@ -10,7 +10,12 @@ from alembic.operations import Operations from sqlalchemy import create_mock_engine from sqlalchemy.dialects import postgresql -from core.compute_nodes import _enrollment_digest, _hash_secret, _verify_secret +from core.compute_nodes import ( + _enrollment_digest, + _hash_secret, + _verify_secret, + delete_node, +) from web.routers.compute_nodes import NodeConnectionManager, _bearer @@ -81,5 +86,24 @@ class ComputeNodeMigrationTests(unittest.TestCase): self.assertIn("ix_compute_nodes_status", rendered) +class ComputeNodeDeleteTests(unittest.TestCase): + @patch("core.compute_nodes.session_scope") + def test_delete_node_removes_existing_identity(self, session_scope) -> None: + session = session_scope.return_value.__enter__.return_value + node = object() + session.get.return_value = node + + self.assertTrue(delete_node(uuid4())) + session.delete.assert_called_once_with(node) + + @patch("core.compute_nodes.session_scope") + def test_delete_node_reports_missing_identity(self, session_scope) -> None: + session = session_scope.return_value.__enter__.return_value + session.get.return_value = None + + self.assertFalse(delete_node(uuid4())) + session.delete.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index 1bb7036..b0e6ea7 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -36,6 +36,12 @@ class StaticVendorTests(unittest.TestCase): self.assertIn('capabilities: ["origin.plot@v1"]', admin_js) self.assertIn("ttl_seconds: 600", admin_js) self.assertIn("navigator.clipboard.writeText(value)", admin_js) + self.assertIn('apiGet("/v1/admin/compute-nodes")', admin_js) + self.assertIn('apiSend("PATCH", `/v1/admin/compute-nodes/${node.node_id}`', admin_js) + self.assertIn('apiSend("DELETE", `/v1/admin/compute-nodes/${node.node_id}`', admin_js) + self.assertIn("最近心跳", admin_js) + self.assertIn("重新启用", admin_js) + self.assertIn("永久删除", admin_js) self.assertNotIn("localStorage.setItem", admin_js) def test_dev_html_uses_local_markdown_vendor_assets(self) -> None: diff --git a/tests/test_web_routes_nodb.py b/tests/test_web_routes_nodb.py index 17be34f..21a9820 100644 --- a/tests/test_web_routes_nodb.py +++ b/tests/test_web_routes_nodb.py @@ -110,6 +110,8 @@ class AuthGateTests(unittest.TestCase): ("POST", "/v1/tasks"), ("POST", "/v1/asr/transcribe"), ("GET", "/v1/admin/overview"), + ("GET", "/v1/admin/compute-nodes"), + ("DELETE", "/v1/admin/compute-nodes/00000000-0000-0000-0000-000000000000"), ("GET", "/v1/admin/tool-wire-health"), ("GET", "/v1/admin/external-system-definitions"), ("GET", "/v1/admin/external-system-users"), diff --git a/web/routers/compute_nodes.py b/web/routers/compute_nodes.py index 4a29b06..6bb0c5c 100644 --- a/web/routers/compute_nodes.py +++ b/web/routers/compute_nodes.py @@ -11,6 +11,7 @@ from core.compute_nodes import ( ComputeNodeError, authenticate_node, create_enrollment, + delete_node, enroll_node, list_nodes, mark_node_offline, @@ -144,3 +145,14 @@ def register_compute_node_routes(app, *, require_admin) -> None: "node_id": str(node_id), "status": "disabled" if body.disabled else "offline", } + + @app.delete("/v1/admin/compute-nodes/{node_id}", tags=["admin"]) + async def admin_delete_compute_node( + node_id: UUID, + user_id: UUID = Depends(require_admin), # noqa: B008 + ): + # 先撤掉在线连接,避免删除后的旧 socket 继续上报运行态。 + await node_connections.close(node_id) + if not await asyncio.to_thread(delete_node, node_id): + raise HTTPException(404, "compute node not found") + return {"node_id": str(node_id), "status": "deleted"} diff --git a/web/static/admin.html b/web/static/admin.html index 797604c..d9a424b 100644 --- a/web/static/admin.html +++ b/web/static/admin.html @@ -167,6 +167,22 @@ #s-windows-node button.primary, #node-enrollment-modal button.primary { color: #fff; border-color: var(--accent); background: var(--accent); } + #s-windows-node button.danger { + color: var(--danger); border-color: #edc1bd; background: #fff; + } + #s-windows-node button:disabled { opacity: .55; cursor: default; } + .node-status { + display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; + border-radius: 999px; background: #f1f1f1; color: var(--muted); + } + .node-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } + .node-status.online { color: var(--ok); background: #e8f5e9; } + .node-status.offline { color: var(--muted); } + .node-status.disabled { color: var(--danger); background: var(--accent-soft); } + .node-name { font-weight: 600; } + .node-id { color: var(--muted); font: 11px/1.4 var(--mono); } + .node-runtime { max-width: 280px; white-space: normal; color: var(--muted); } + .node-actions { text-align: right; } #node-enrollment-modal .card { width: min(520px, calc(100vw - 32px)); margin: 0; padding: 0; } .node-modal-head, .node-modal-actions { display: flex; align-items: center; gap: 8px; padding: 11px 16px; diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 9c05c6f..45999e5 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -4,7 +4,7 @@ // 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、 // 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。 import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js"; -import { dialogPrompt, message } from "./dialog.js"; +import { dialogConfirm, dialogPrompt, message } from "./dialog.js"; const LS_TOKEN = "zcbot.token"; const REFRESH_MS = 10000; @@ -55,6 +55,7 @@ let externalDefinitions = []; let externalUsers = []; let externalDefinitionsLoaded = false; let externalEditingId = ""; +let computeNodes = []; // ───── 格式化 ───── function fmtCNY(n) { @@ -159,12 +160,99 @@ function renderByDay(rows) { + `${body}${foot}`; } -function renderWindowsNodeEnrollment() { +function nodeStatusHTML(status) { + const labels = { online: "在线", offline: "离线", disabled: "已禁用" }; + const value = labels[status] ? status : "offline"; + return `${labels[value]}`; +} + +function renderWindowsNodes() { + const rows = computeNodes.map(node => { + const runtime = node.runtime || {}; + const runtimeParts = [ + node.os_version || "", + runtime.desktop_session === true ? "桌面会话" : "", + runtime.available_slots != null ? `可用槽位 ${runtime.available_slots}` : "", + ].filter(Boolean); + const lastSeen = node.last_seen_at + ? `${escapeHtml(fmtTimeAgo(node.last_seen_at))}` + : "—"; + const disabled = node.status === "disabled"; + return `` + + `
${escapeHtml(node.name || "未命名节点")}
` + + `
${escapeHtml(node.node_id)}
` + + `${nodeStatusHTML(node.status)}` + + `${escapeHtml(runtimeParts.join(" · ") || "—")}` + + `${escapeHtml(node.node_version || "—")}` + + `${(node.capabilities || []).map(x => `${escapeHtml(x)}`).join(" ") || "—"}` + + `${lastSeen}` + + ` ` + + `` + + ``; + }).join("") || `尚无已注册的 Windows Node`; + $("s-windows-node").innerHTML = `
` - + `

Windows Node

为新的 Windows 计算节点生成短时、一次性注册码。
` + + `

Windows Node(${computeNodes.length})

查看节点状态;禁用会立即断开节点并拒绝后续连接。
` + `` - + `
`; + + `
` + + `${rows}
节点状态运行环境版本能力最近心跳操作
`; $("node-enrollment-open").onclick = openNodeEnrollmentModal; + $("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => { + button.onclick = () => toggleComputeNode(button); + }); + $("s-windows-node").querySelectorAll("[data-node-delete]").forEach(button => { + button.onclick = () => deleteComputeNode(button); + }); +} + +async function toggleComputeNode(button) { + const row = button.closest("tr[data-node-id]"); + const node = computeNodes.find(item => item.node_id === row?.dataset.nodeId); + if (!node) return; + const disabling = button.dataset.nodeToggle === "disable"; + const confirmed = await dialogConfirm({ + title: disabling ? "禁用 Windows Node" : "重新启用 Windows Node", + message: disabling + ? `禁用「${node.name}」?在线连接会立即断开,之后不能接收任务。` + : `重新启用「${node.name}」?启用后需在该电脑托盘菜单点击“立即重连”。`, + okText: disabling ? "禁用" : "重新启用", + danger: disabling, + }); + if (!confirmed) return; + button.disabled = true; + try { + await apiSend("PATCH", `/v1/admin/compute-nodes/${node.node_id}`, { + disabled: disabling, + }); + message(disabling ? "节点已禁用" : "节点已重新启用,请在节点电脑上立即重连", "success", 5000); + await loadComputeNodes(); + } catch (err) { + if (err.code !== "auth") message("更新节点失败:" + (err.message || String(err)), "error", 5000); + button.disabled = false; + } +} + +async function deleteComputeNode(button) { + const row = button.closest("tr[data-node-id]"); + const node = computeNodes.find(item => item.node_id === row?.dataset.nodeId); + if (!node) return; + const confirmed = await dialogConfirm({ + title: "删除 Windows Node", + message: `永久删除「${node.name}」?节点会立即断开,本机现有身份失效;如需再次使用,必须清除本机身份并用新注册码重新注册。`, + okText: "永久删除", + danger: true, + }); + if (!confirmed) return; + button.disabled = true; + try { + await apiSend("DELETE", `/v1/admin/compute-nodes/${node.node_id}`, {}); + message("节点已删除,本机需重新注册后才能使用", "success", 5000); + await loadComputeNodes(); + } catch (err) { + if (err.code !== "auth") message("删除节点失败:" + (err.message || String(err)), "error", 5000); + button.disabled = false; + } } function openNodeEnrollmentModal() { @@ -817,7 +905,7 @@ function renderMetrics(d) { $("s-usage").innerHTML = renderUsersAndUsage(d.users || {}, d.usage || {}) + renderByDay((d.usage || {}).by_day_7d); - renderWindowsNodeEnrollment(); + renderWindowsNodes(); } function showMsg(html) { @@ -943,6 +1031,14 @@ async function loadExternalDefinitions(force = false) { } catch (e) { /* overview 统一处理鉴权 */ } } +async function loadComputeNodes() { + try { + const result = await apiGet("/v1/admin/compute-nodes"); + computeNodes = result.results || []; + renderWindowsNodes(); + } catch (e) { /* overview 统一处理鉴权 */ } +} + // overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新四个独立表(保持各自状态) async function refresh() { try { @@ -952,6 +1048,7 @@ async function refresh() { loadModels(); loadUserUsage(userPage); loadStorage(storagePage); + loadComputeNodes(); loadExternalDefinitions(); loadToolFailures(); } catch (e) {