diff --git a/core/software_nodes.py b/core/software_nodes.py index d7b40a1..450809e 100644 --- a/core/software_nodes.py +++ b/core/software_nodes.py @@ -8,13 +8,19 @@ from hashlib import sha256 from uuid import UUID, uuid4 import bcrypt -from sqlalchemy import select, update +from sqlalchemy import func, select, update from core.software_contracts import supported_capabilities 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 +ACTIVE_JOB_STATUSES = {"offered", "dispatched", "running", "disconnected", "cancelling"} class SoftwareNodeError(Exception): @@ -208,6 +214,16 @@ def list_nodes() -> list[dict]: .scalars() .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 [ { "node_id": str(row.node_id), @@ -217,6 +233,7 @@ def list_nodes() -> list[dict]: "node_version": row.node_version, "os_version": row.os_version, "runtime": row.runtime, + "active_job_count": int(active_job_counts.get(row.node_id, 0)), "last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None, diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 86cc4a7..2b5ba97 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -34,6 +34,7 @@ from core.software_nodes import ( _verify_secret, create_enrollment, delete_node, + list_nodes, update_node_runtime, ) from web.routers.software_nodes import NodeConnectionManager, _bearer @@ -122,6 +123,30 @@ class SoftwareNodeSecurityTests(unittest.TestCase): ) 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): async def test_new_connection_replaces_old_without_removing_new(self) -> None: diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index dd277fb..6b791e8 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -87,6 +87,8 @@ class StaticVendorTests(unittest.TestCase): 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("最近心跳", admin_js) + self.assertIn("活动任务", admin_js) + self.assertIn("node.active_job_count", admin_js) self.assertIn("重新启用", admin_js) self.assertIn("永久删除", admin_js) self.assertNotIn("localStorage.setItem", admin_js) diff --git a/web/static/admin.html b/web/static/admin.html index e811e64..7114e88 100644 --- a/web/static/admin.html +++ b/web/static/admin.html @@ -228,13 +228,20 @@ .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(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(5), .node-table td:nth-child(5) { width: 150px; } + .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: 90px; } + .node-table th:nth-child(6), .node-table td:nth-child(6) { width: 150px; } .node-capabilities { min-width: 360px; } .node-capability { display: flex; align-items: center; gap: 8px; min-width: max-content; } .node-capability + .node-capability { margin-top: 5px; } .node-capability-version { color: var(--muted); font: 11px/1.4 var(--mono); } .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-enrollment-modal .card { width: min(520px, calc(100vw - 32px)); margin: 0; padding: 0; } .node-modal-head, .node-modal-actions { diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 307ed8a..d732bc4 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -208,24 +208,26 @@ function renderWindowsNodes() { ? `${escapeHtml(fmtTimeAgo(node.last_seen_at))}` : "—"; const disabled = node.status === "disabled"; + const activeJobCount = Math.max(0, Number(node.active_job_count) || 0); return `` + `
${escapeHtml(node.name || "未命名节点")}
` + `
${escapeHtml(node.node_id)}
` + `
${escapeHtml(node.node_version ? `Node ${node.node_version}` : "Node 版本未上报")}
` + `${nodeStatusHTML(node.status)}` + `${capabilityHTML || `暂无可用能力`}` + + `${activeJobCount}` + `${lastSeen}` + ` ` + `` + ``; - }).join("") || `尚无已注册的 Windows Node`; + }).join("") || `尚无已注册的 Windows Node`; $("s-windows-node").innerHTML = `
` + `

Windows Node(${softwareNodes.length})

查看节点状态;禁用会立即断开节点并拒绝后续连接。
` + `` + `
` - + `${rows}
节点 / Node 版本状态可用能力 / 软件版本最近心跳操作
`; + + `可用能力 / 软件版本活动任务最近心跳操作${rows}`; $("node-enrollment-open").onclick = openNodeEnrollmentModal; $("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => { button.onclick = () => toggleSoftwareNode(button);