feat(admin): show active Windows Node jobs
This commit is contained in:
parent
a7c3ee8b16
commit
0bc6d695ef
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -208,24 +208,26 @@ function renderWindowsNodes() {
|
|||
? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>`
|
||||
: "—";
|
||||
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)}">`
|
||||
+ `<td><div class="node-name">${escapeHtml(node.name || "未命名节点")}</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>`
|
||||
+ `<td>${nodeStatusHTML(node.status)}</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 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="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">`
|
||||
+ `<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>`
|
||||
+ `</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;
|
||||
$("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => {
|
||||
button.onclick = () => toggleSoftwareNode(button);
|
||||
|
|
|
|||
Loading…
Reference in New Issue