feat(admin): manage Windows compute nodes
This commit is contained in:
parent
fac4e5fa0a
commit
b555059bc4
|
|
@ -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 分钟有效、仅可使用一次的注册码。
|
||||
|
|
|
|||
|
|
@ -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
4
RUN.md
|
|
@ -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/`
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.65.0"
|
||||
__version__ = "0.65.1"
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue