feat(admin): show active Windows Node jobs

This commit is contained in:
caoqianming 2026-08-28 13:40:44 +08:00
parent a7c3ee8b16
commit 0bc6d695ef
5 changed files with 59 additions and 6 deletions

View File

@ -8,13 +8,19 @@ from hashlib import sha256
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import bcrypt import bcrypt
from sqlalchemy import select, update from sqlalchemy import func, select, update
from core.software_contracts import supported_capabilities from core.software_contracts import supported_capabilities
from core.storage.engine import session_scope from core.storage.engine import session_scope
from core.storage.models import SoftwareNode, SoftwareNodeEnrollment, SoftwareWorkspace from core.storage.models import (
SoftwareJob,
SoftwareNode,
SoftwareNodeEnrollment,
SoftwareWorkspace,
)
MAX_ENROLLMENT_FAILURES = 5 MAX_ENROLLMENT_FAILURES = 5
ACTIVE_JOB_STATUSES = {"offered", "dispatched", "running", "disconnected", "cancelling"}
class SoftwareNodeError(Exception): class SoftwareNodeError(Exception):
@ -208,6 +214,16 @@ def list_nodes() -> list[dict]:
.scalars() .scalars()
.all() .all()
) )
active_job_counts = dict(
session.execute(
select(SoftwareJob.node_id, func.count(SoftwareJob.job_id))
.where(
SoftwareJob.node_id.is_not(None),
SoftwareJob.status.in_(ACTIVE_JOB_STATUSES),
)
.group_by(SoftwareJob.node_id)
).all()
)
return [ return [
{ {
"node_id": str(row.node_id), "node_id": str(row.node_id),
@ -217,6 +233,7 @@ def list_nodes() -> list[dict]:
"node_version": row.node_version, "node_version": row.node_version,
"os_version": row.os_version, "os_version": row.os_version,
"runtime": row.runtime, "runtime": row.runtime,
"active_job_count": int(active_job_counts.get(row.node_id, 0)),
"last_seen_at": row.last_seen_at.isoformat() "last_seen_at": row.last_seen_at.isoformat()
if row.last_seen_at if row.last_seen_at
else None, else None,

View File

@ -34,6 +34,7 @@ from core.software_nodes import (
_verify_secret, _verify_secret,
create_enrollment, create_enrollment,
delete_node, delete_node,
list_nodes,
update_node_runtime, update_node_runtime,
) )
from web.routers.software_nodes import NodeConnectionManager, _bearer from web.routers.software_nodes import NodeConnectionManager, _bearer
@ -122,6 +123,30 @@ class SoftwareNodeSecurityTests(unittest.TestCase):
) )
self.assertIsNotNone(node.last_seen_at) self.assertIsNotNone(node.last_seen_at)
@patch("core.software_nodes.session_scope")
def test_admin_node_list_includes_active_job_count(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value
node_id = uuid4()
node = type("Node", (), {
"node_id": node_id,
"name": "绘图节点",
"status": "online",
"capabilities": ["origin.plot@v2"],
"node_version": "0.2.0",
"os_version": "Windows",
"runtime": {},
"last_seen_at": datetime.now(timezone.utc),
})()
node_result = MagicMock()
node_result.scalars.return_value.all.return_value = [node]
count_result = MagicMock()
count_result.all.return_value = [(node_id, 1)]
session.execute.side_effect = [node_result, count_result]
result = list_nodes()
self.assertEqual(result[0]["active_job_count"], 1)
class SoftwareNodeConnectionTests(unittest.IsolatedAsyncioTestCase): class SoftwareNodeConnectionTests(unittest.IsolatedAsyncioTestCase):
async def test_new_connection_replaces_old_without_removing_new(self) -> None: async def test_new_connection_replaces_old_without_removing_new(self) -> None:

View File

@ -87,6 +87,8 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn('apiSend("PATCH", `/v1/admin/software-nodes/${node.node_id}`', admin_js) self.assertIn('apiSend("PATCH", `/v1/admin/software-nodes/${node.node_id}`', admin_js)
self.assertIn('apiSend("DELETE", `/v1/admin/software-nodes/${node.node_id}`', admin_js) self.assertIn('apiSend("DELETE", `/v1/admin/software-nodes/${node.node_id}`', admin_js)
self.assertIn("最近心跳", admin_js) self.assertIn("最近心跳", admin_js)
self.assertIn("活动任务", admin_js)
self.assertIn("node.active_job_count", admin_js)
self.assertIn("重新启用", admin_js) self.assertIn("重新启用", admin_js)
self.assertIn("永久删除", admin_js) self.assertIn("永久删除", admin_js)
self.assertNotIn("localStorage.setItem", admin_js) self.assertNotIn("localStorage.setItem", admin_js)

View File

@ -228,13 +228,20 @@
.node-table th:nth-child(1), .node-table td:nth-child(1) { width: 260px; } .node-table th:nth-child(1), .node-table td:nth-child(1) { width: 260px; }
.node-table th:nth-child(2), .node-table td:nth-child(2) { width: 80px; text-align: left; } .node-table th:nth-child(2), .node-table td:nth-child(2) { width: 80px; text-align: left; }
.node-table th:nth-child(3), .node-table td:nth-child(3) { text-align: left; } .node-table th:nth-child(3), .node-table td:nth-child(3) { text-align: left; }
.node-table th:nth-child(4), .node-table td:nth-child(4) { width: 90px; } .node-table th:nth-child(4), .node-table td:nth-child(4) { width: 92px; text-align: center; }
.node-table th:nth-child(5), .node-table td:nth-child(5) { width: 150px; } .node-table th:nth-child(5), .node-table td:nth-child(5) { width: 90px; }
.node-table th:nth-child(6), .node-table td:nth-child(6) { width: 150px; }
.node-capabilities { min-width: 360px; } .node-capabilities { min-width: 360px; }
.node-capability { display: flex; align-items: center; gap: 8px; min-width: max-content; } .node-capability { display: flex; align-items: center; gap: 8px; min-width: max-content; }
.node-capability + .node-capability { margin-top: 5px; } .node-capability + .node-capability { margin-top: 5px; }
.node-capability-version { color: var(--muted); font: 11px/1.4 var(--mono); } .node-capability-version { color: var(--muted); font: 11px/1.4 var(--mono); }
.node-empty-capability { color: var(--muted); } .node-empty-capability { color: var(--muted); }
.node-task-count {
display: inline-flex; align-items: center; justify-content: center; min-width: 28px;
padding: 2px 8px; border-radius: 999px; background: #f1f3f4; color: var(--muted);
font: 600 12px/1.5 var(--mono);
}
.node-task-count.busy { background: #fff0de; color: var(--warn); }
.node-actions { text-align: right; } .node-actions { text-align: right; }
#node-enrollment-modal .card { width: min(520px, calc(100vw - 32px)); margin: 0; padding: 0; } #node-enrollment-modal .card { width: min(520px, calc(100vw - 32px)); margin: 0; padding: 0; }
.node-modal-head, .node-modal-actions { .node-modal-head, .node-modal-actions {

View File

@ -208,24 +208,26 @@ function renderWindowsNodes() {
? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>` ? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>`
: "—"; : "—";
const disabled = node.status === "disabled"; const disabled = node.status === "disabled";
const activeJobCount = Math.max(0, Number(node.active_job_count) || 0);
return `<tr data-node-id="${escapeHtml(node.node_id)}">` return `<tr data-node-id="${escapeHtml(node.node_id)}">`
+ `<td><div class="node-name">${escapeHtml(node.name || "未命名节点")}</div>` + `<td><div class="node-name">${escapeHtml(node.name || "未命名节点")}</div>`
+ `<div class="node-id">${escapeHtml(node.node_id)}</div>` + `<div class="node-id">${escapeHtml(node.node_id)}</div>`
+ `<div class="node-version">${escapeHtml(node.node_version ? `Node ${node.node_version}` : "Node 版本未上报")}</div></td>` + `<div class="node-version">${escapeHtml(node.node_version ? `Node ${node.node_version}` : "Node 版本未上报")}</div></td>`
+ `<td>${nodeStatusHTML(node.status)}</td>` + `<td>${nodeStatusHTML(node.status)}</td>`
+ `<td class="node-capabilities">${capabilityHTML || `<span class="node-empty-capability">暂无可用能力</span>`}</td>` + `<td class="node-capabilities">${capabilityHTML || `<span class="node-empty-capability">暂无可用能力</span>`}</td>`
+ `<td><span class="node-task-count ${activeJobCount ? "busy" : ""}" title="占用该节点的活动任务数">${activeJobCount}</span></td>`
+ `<td>${lastSeen}</td>` + `<td>${lastSeen}</td>`
+ `<td class="node-actions"><button type="button" data-node-toggle="${disabled ? "enable" : "disable"}" ` + `<td class="node-actions"><button type="button" data-node-toggle="${disabled ? "enable" : "disable"}" `
+ `class="${disabled ? "" : "danger"}">${disabled ? "重新启用" : "禁用"}</button> ` + `class="${disabled ? "" : "danger"}">${disabled ? "重新启用" : "禁用"}</button> `
+ `<button type="button" data-node-delete class="danger">删除</button></td>` + `<button type="button" data-node-delete class="danger">删除</button></td>`
+ `</tr>`; + `</tr>`;
}).join("") || `<tr><td colspan="5" class="empty">尚无已注册的 Windows Node</td></tr>`; }).join("") || `<tr><td colspan="6" class="empty">尚无已注册的 Windows Node</td></tr>`;
$("s-windows-node").innerHTML = `<div class="card"><div class="card-head">` $("s-windows-node").innerHTML = `<div class="card"><div class="card-head">`
+ `<div><h2>Windows Node${softwareNodes.length}</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>` + `<div><h2>Windows Node${softwareNodes.length}</h2><div class="node-help">查看节点状态;禁用会立即断开节点并拒绝后续连接。</div></div>`
+ `<button id="node-enrollment-open" class="primary" type="button">生成 Windows Node 注册码</button>` + `<button id="node-enrollment-open" class="primary" type="button">生成 Windows Node 注册码</button>`
+ `</div><div class="scroll-x"><table class="node-table"><thead><tr><th>节点 / Node 版本</th><th>状态</th>` + `</div><div class="scroll-x"><table class="node-table"><thead><tr><th>节点 / Node 版本</th><th>状态</th>`
+ `<th>可用能力 / 软件版本</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`; + `<th>可用能力 / 软件版本</th><th>活动任务</th><th>最近心跳</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div></div>`;
$("node-enrollment-open").onclick = openNodeEnrollmentModal; $("node-enrollment-open").onclick = openNodeEnrollmentModal;
$("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => { $("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => {
button.onclick = () => toggleSoftwareNode(button); button.onclick = () => toggleSoftwareNode(button);