feat(admin): manage Windows compute nodes

This commit is contained in:
caoqianming 2026-08-12 17:17:02 +08:00
parent fac4e5fa0a
commit b555059bc4
12 changed files with 200 additions and 9 deletions

View File

@ -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 分钟有效、仅可使用一次的注册码。

View File

@ -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。

4
RUN.md
View File

@ -1048,7 +1048,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
- 管理员 `POST /v1/admin/compute-node-enrollments` 创建一次性注册码;
- Node `POST /v1/compute/nodes/enroll` 注册并一次性取得 `node_id`、`node_token`
- Node 携带 `Authorization: Bearer <node_token>``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/`

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.65.0"
__version__ = "0.65.1"

View File

@ -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 = (

View File

@ -58,6 +58,22 @@ server {
proxy_send_timeout 300s;
}
# ★ Windows Node 长连接:注册走普通 POST,注册后的节点控制通道走 WebSocket。
# 必须独立于下面会清空 Connection 头的默认 location15s 应用心跳保持链路活跃。
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;

View File

@ -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()

View File

@ -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:

View File

@ -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"),

View File

@ -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"}

View File

@ -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;

View File

@ -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) {
+ `<tbody>${body}</tbody>${foot}</table></div></div>`;
}
function renderWindowsNodeEnrollment() {
function nodeStatusHTML(status) {
const labels = { online: "在线", offline: "离线", disabled: "已禁用" };
const value = labels[status] ? status : "offline";
return `<span class="node-status ${value}">${labels[value]}</span>`;
}
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
? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>`
: "—";
const disabled = node.status === "disabled";
return `<tr data-node-id="${escapeHtml(node.node_id)}">`
+ `<td><div class="node-name">${escapeHtml(node.name || "未命名节点")}</div>`
+ `<div class="node-id">${escapeHtml(node.node_id)}</div></td>`
+ `<td>${nodeStatusHTML(node.status)}</td>`
+ `<td class="node-runtime">${escapeHtml(runtimeParts.join(" · ") || "—")}</td>`
+ `<td>${escapeHtml(node.node_version || "—")}</td>`
+ `<td>${(node.capabilities || []).map(x => `<span class="chip">${escapeHtml(x)}</span>`).join(" ") || "—"}</td>`
+ `<td>${lastSeen}</td>`
+ `<td class="node-actions"><button type="button" data-node-toggle="${disabled ? "enable" : "disable"}" `
+ `class="${disabled ? "" : "danger"}">${disabled ? "重新启用" : "禁用"}</button> `
+ `<button type="button" data-node-delete class="danger">删除</button></td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="7" class="empty">尚无已注册的 Windows Node</td></tr>`;
$("s-windows-node").innerHTML = `<div class="card"><div class="card-head">`
+ `<div><h2>Windows Node</h2><div class="node-help">为新的 Windows 计算节点生成短时、一次性注册码。</div></div>`
+ `<div><h2>Windows Node${computeNodes.length}</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>`
+ `<button id="node-enrollment-open" class="primary" type="button">生成 Windows Node 注册码</button>`
+ `</div></div>`;
+ `</div><div class="scroll-x"><table><thead><tr><th>节点</th><th>状态</th><th>运行环境</th>`
+ `<th>版本</th><th>能力</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
$("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) {