diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py
index 3479f7b..8c037e1 100644
--- a/tests/test_static_vendor.py
+++ b/tests/test_static_vendor.py
@@ -74,8 +74,12 @@ class StaticVendorTests(unittest.TestCase):
self.assertNotIn('capabilities: ["origin.plot@v2"]', admin_js)
self.assertIn("节点能力由客户端自动发现并上报", html)
self.assertIn("runtime.capability_runtime", admin_js)
+ self.assertIn('item.health === "ready"', admin_js)
self.assertIn("item.adapter_version", admin_js)
self.assertIn("item.software_version", admin_js)
+ self.assertIn("可用能力 / 软件版本", admin_js)
+ self.assertNotIn("自动发现能力", admin_js)
+ self.assertIn(".node-capability { display: flex", html)
self.assertIn('runtime.origin ? [["origin.plot@v2", runtime.origin]]', admin_js)
self.assertIn("ttl_seconds: 600", admin_js)
self.assertIn("navigator.clipboard.writeText(value)", admin_js)
diff --git a/web/static/admin.html b/web/static/admin.html
index c3420ba..5e7564c 100644
--- a/web/static/admin.html
+++ b/web/static/admin.html
@@ -181,7 +181,18 @@
.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-version { margin-top: 3px; color: var(--muted); font: 11px/1.4 var(--mono); }
+ .node-table { min-width: 820px; }
+ .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-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-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 cf203c2..93c5792 100644
--- a/web/static/js/admin.js
+++ b/web/static/js/admin.js
@@ -178,52 +178,54 @@ function nodeSoftwareName(capability, item) {
return item.software || capability;
}
+function nodeAvailableCapabilities(node, runtime) {
+ const reported = new Set(node.capabilities || []);
+ return nodeCapabilityRuntimes(runtime).filter(([capability, item]) =>
+ item.health === "ready" && (!reported.size || reported.has(capability))
+ );
+}
+
+function nodeCapabilityVersionHTML(capability, item) {
+ const name = nodeSoftwareName(capability, item);
+ const versions = [
+ item.software_version ? `${name} ${item.software_version}` : name,
+ item.adapter_version ? `Adapter ${item.adapter_version}` : "",
+ ].filter(Boolean);
+ return `
`
+ + `${escapeHtml(capability)}`
+ + `${escapeHtml(versions.join(" · "))}`
+ + `
`;
+}
+
function renderWindowsNodes() {
const rows = softwareNodes.map(node => {
const runtime = node.runtime || {};
- const capabilityRuntimes = nodeCapabilityRuntimes(runtime);
- const runtimeParts = [
- node.os_version || "",
- runtime.desktop_session === true ? "桌面会话" : "",
- runtime.available_slots != null ? `可用槽位 ${runtime.available_slots}` : "",
- ...capabilityRuntimes.map(([capability, item]) => {
- const name = nodeSoftwareName(capability, item);
- return `${name}${item.health === "ready" ? "可用" : "不可用"}`;
- }),
- ].filter(Boolean);
- const versionParts = [
- node.node_version ? `Node ${node.node_version}` : "",
- ...capabilityRuntimes.flatMap(([capability, item]) => {
- const name = nodeSoftwareName(capability, item);
- return [
- item.adapter_version ? `${name} Adapter ${item.adapter_version}` : "",
- item.software_version ? `${name} ${item.software_version}` : "",
- ];
- }),
- ].filter(Boolean);
+ const availableCapabilities = nodeAvailableCapabilities(node, runtime);
+ const capabilityHTML = availableCapabilities
+ .map(([capability, item]) => nodeCapabilityVersionHTML(capability, item))
+ .join("");
const lastSeen = node.last_seen_at
? `${escapeHtml(fmtTimeAgo(node.last_seen_at))}`
: "—";
const disabled = node.status === "disabled";
return ``
+ `${escapeHtml(node.name || "未命名节点")} `
- + `${escapeHtml(node.node_id)} | `
+ + `${escapeHtml(node.node_id)}
`
+ + `${escapeHtml(node.node_version ? `Node ${node.node_version}` : "Node 版本未上报")}
`
+ `${nodeStatusHTML(node.status)} | `
- + `${escapeHtml(runtimeParts.join(" · ") || "—")} | `
- + `${escapeHtml(versionParts.join(" · ") || "—")} | `
- + `${(node.capabilities || []).map(x => `${escapeHtml(x)}`).join(" ") || "—"} | `
+ + `${capabilityHTML || `暂无可用能力`} | `
+ `${lastSeen} | `
+ ` `
+ ` | `
+ `
`;
- }).join("") || `| 尚无已注册的 Windows Node |
`;
+ }).join("") || `| 尚无已注册的 Windows Node |
`;
$("s-windows-node").innerHTML = ``
+ `
Windows Node(${softwareNodes.length})
查看节点状态;禁用会立即断开节点并拒绝后续连接。
`
+ `
`
- + `
`;
+ + ``;
$("node-enrollment-open").onclick = openNodeEnrollmentModal;
$("s-windows-node").querySelectorAll("[data-node-toggle]").forEach(button => {
button.onclick = () => toggleSoftwareNode(button);