mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): show node fleet drift and wake status (#104735)
* feat(ui): show node fleet drift and wake status * chore: keep release changelog ownership * test(ui): compare node drift labels explicitly
This commit is contained in:
committed by
GitHub
parent
1b1cebfe42
commit
e37e4da2e8
@@ -36,6 +36,8 @@ describe("buildNodesInventory", () => {
|
||||
caps: ["screen"],
|
||||
commands: ["system.run"],
|
||||
version: "2026.6.11",
|
||||
coreVersion: "2026.7.2",
|
||||
uiVersion: "19.5",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -47,6 +49,8 @@ describe("buildNodesInventory", () => {
|
||||
expect(entry.roles).toEqual(["operator", "node"]);
|
||||
expect(entry.version).toBe("2026.6.11");
|
||||
expect(entry.node?.caps).toEqual(["screen"]);
|
||||
expect(entry.node?.coreVersion).toBe("2026.7.2");
|
||||
expect(entry.node?.uiVersion).toBe("19.5");
|
||||
});
|
||||
|
||||
it("joins presence case-insensitively and prefers its display metadata", () => {
|
||||
|
||||
@@ -19,6 +19,8 @@ export type NodeListEntry = {
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
coreVersion?: string;
|
||||
uiVersion?: string;
|
||||
modelIdentifier?: string;
|
||||
clientId?: string;
|
||||
clientMode?: string;
|
||||
@@ -94,6 +96,8 @@ export function parseNodeListEntry(raw: Record<string, unknown>): NodeListEntry
|
||||
displayName: normalizeOptionalString(raw.displayName),
|
||||
platform: normalizeOptionalString(raw.platform),
|
||||
version: normalizeOptionalString(raw.version),
|
||||
coreVersion: normalizeOptionalString(raw.coreVersion),
|
||||
uiVersion: normalizeOptionalString(raw.uiVersion),
|
||||
modelIdentifier: normalizeOptionalString(raw.modelIdentifier),
|
||||
clientId: normalizeOptionalString(raw.clientId),
|
||||
clientMode: normalizeOptionalString(raw.clientMode),
|
||||
|
||||
@@ -350,6 +350,10 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
|
||||
|
||||
override render() {
|
||||
const config = this.context.runtimeConfig.state;
|
||||
const gatewaySnapshot = this.context.gateway.snapshot;
|
||||
const gatewayVersion = gatewaySnapshot.connected
|
||||
? gatewaySnapshot.hello?.server?.version?.trim() || null
|
||||
: null;
|
||||
return html`
|
||||
<section class="content-header">
|
||||
<div>
|
||||
@@ -362,6 +366,7 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
|
||||
loading: this.nodesLoading,
|
||||
nodes: this.nodes,
|
||||
presence: this.presence,
|
||||
gatewayVersion,
|
||||
lastError: this.lastError,
|
||||
devicesLoading: this.devicesLoading,
|
||||
devicesError: this.devicesError,
|
||||
|
||||
@@ -133,7 +133,42 @@ function renderInventoryGroup(group: NodesInventoryGroup, props: NodesProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function entryStatusChips(entry: NodesInventoryEntry): TemplateResult[] {
|
||||
function isWindowsPlatform(platform: string | undefined): boolean {
|
||||
const normalized = normalizeOptionalString(platform)?.toLowerCase();
|
||||
return (
|
||||
normalized === "win32" ||
|
||||
normalized === "windows" ||
|
||||
normalized?.startsWith("windows ") === true
|
||||
);
|
||||
}
|
||||
|
||||
function isApprovedNodeEntry(entry: NodesInventoryEntry): boolean {
|
||||
const node = entry.node;
|
||||
if (!node?.paired) {
|
||||
return false;
|
||||
}
|
||||
return node.approvalState === undefined || node.approvalState === "approved";
|
||||
}
|
||||
|
||||
function resolveNodeCoreVersion(entry: NodesInventoryEntry): string | undefined {
|
||||
const coreVersion = normalizeOptionalString(entry.node?.coreVersion);
|
||||
if (coreVersion) {
|
||||
return coreVersion;
|
||||
}
|
||||
if (normalizeOptionalString(entry.node?.uiVersion)) {
|
||||
return undefined;
|
||||
}
|
||||
const platform = normalizeOptionalString(entry.node?.platform)?.toLowerCase();
|
||||
// Legacy headless desktop nodes reported one version field as their core version.
|
||||
const legacyHeadless =
|
||||
platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows";
|
||||
return legacyHeadless ? normalizeOptionalString(entry.node?.version) : undefined;
|
||||
}
|
||||
|
||||
function entryStatusChips(
|
||||
entry: NodesInventoryEntry,
|
||||
gatewayVersion: string | null,
|
||||
): TemplateResult[] {
|
||||
const chips: TemplateResult[] = [];
|
||||
for (const role of entry.roles) {
|
||||
chips.push(html`<span class="chip">${role}</span>`);
|
||||
@@ -141,6 +176,23 @@ function entryStatusChips(entry: NodesInventoryEntry): TemplateResult[] {
|
||||
if (entry.autoApproved) {
|
||||
chips.push(html`<span class="chip">auto-paired</span>`);
|
||||
}
|
||||
const isApprovedNode = isApprovedNodeEntry(entry);
|
||||
const nodeVersion = resolveNodeCoreVersion(entry);
|
||||
const normalizedGatewayVersion = normalizeOptionalString(gatewayVersion);
|
||||
if (
|
||||
isApprovedNode &&
|
||||
nodeVersion &&
|
||||
normalizedGatewayVersion &&
|
||||
nodeVersion !== normalizedGatewayVersion
|
||||
) {
|
||||
const title = `Node ${nodeVersion}; Gateway ${normalizedGatewayVersion}. Update the older component to align the fleet.`;
|
||||
chips.push(html`<span class="chip chip-warn" title=${title}>version drift</span>`);
|
||||
}
|
||||
if (isApprovedNode && !entry.connected && isWindowsPlatform(entry.platform)) {
|
||||
const title =
|
||||
"The Gateway cannot wake an offline Windows node. Start the machine or restore its network connection.";
|
||||
chips.push(html`<span class="chip chip-warn" title=${title}>manual wake required</span>`);
|
||||
}
|
||||
const approvalState = entry.node?.approvalState;
|
||||
if (approvalState === "pending-approval" || approvalState === "pending-reapproval") {
|
||||
chips.push(html`<span class="chip chip-warn">approval needed</span>`);
|
||||
@@ -257,7 +309,7 @@ function renderInventoryEntry(entry: NodesInventoryEntry, props: NodesProps) {
|
||||
title=${entry.connected ? "connected" : "offline"}
|
||||
></span>
|
||||
<span class="list-title">${entry.name}</span>
|
||||
${entryStatusChips(entry)}
|
||||
${entryStatusChips(entry, props.gatewayVersion)}
|
||||
</div>
|
||||
<div class="list-sub">${entryMetaLine(entry)}</div>
|
||||
${renderEntryDetails(entry, props)}
|
||||
|
||||
@@ -9,6 +9,7 @@ function baseProps(overrides: Partial<NodesProps> = {}): NodesProps {
|
||||
loading: false,
|
||||
nodes: [],
|
||||
presence: [],
|
||||
gatewayVersion: null,
|
||||
lastError: null,
|
||||
devicesLoading: false,
|
||||
devicesError: null,
|
||||
@@ -316,6 +317,122 @@ describe("nodes inventory rendering", () => {
|
||||
expect(approvals).toEqual(["node-req-1"]);
|
||||
});
|
||||
|
||||
it("shows node and Gateway version drift", () => {
|
||||
const container = renderNodesContainer({
|
||||
gatewayVersion: "2026.7.2",
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "node-old",
|
||||
displayName: "Older Mac",
|
||||
version: "19.4",
|
||||
coreVersion: "2026.6.11",
|
||||
uiVersion: "19.4",
|
||||
connected: true,
|
||||
paired: true,
|
||||
},
|
||||
{
|
||||
nodeId: "node-current",
|
||||
displayName: "Current Mac",
|
||||
version: "19.5",
|
||||
coreVersion: "2026.7.2",
|
||||
uiVersion: "19.5",
|
||||
connected: true,
|
||||
paired: true,
|
||||
},
|
||||
{
|
||||
nodeId: "node-newer",
|
||||
displayName: "Newer Mac",
|
||||
version: "19.6",
|
||||
coreVersion: "2026.8.1",
|
||||
uiVersion: "19.6",
|
||||
connected: true,
|
||||
paired: true,
|
||||
},
|
||||
{
|
||||
nodeId: "legacy-linux",
|
||||
displayName: "Legacy Linux",
|
||||
platform: "linux",
|
||||
version: "2026.6.10",
|
||||
connected: true,
|
||||
paired: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const driftChips = Array.from(getInventoryCard(container).querySelectorAll(".chip")).filter(
|
||||
(chip) => chip.textContent?.trim() === "version drift",
|
||||
);
|
||||
|
||||
expect(driftChips).toHaveLength(3);
|
||||
expect(
|
||||
driftChips
|
||||
.map((chip) => chip.getAttribute("title"))
|
||||
.toSorted((left, right) => (left ?? "").localeCompare(right ?? "")),
|
||||
).toEqual([
|
||||
"Node 2026.6.10; Gateway 2026.7.2. Update the older component to align the fleet.",
|
||||
"Node 2026.6.11; Gateway 2026.7.2. Update the older component to align the fleet.",
|
||||
"Node 2026.8.1; Gateway 2026.7.2. Update the older component to align the fleet.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows when an offline Windows node requires manual wake", () => {
|
||||
const container = renderNodesContainer({
|
||||
devicesList: {
|
||||
pending: [],
|
||||
paired: [
|
||||
{
|
||||
deviceId: "windows-browser",
|
||||
displayName: "Windows browser",
|
||||
platform: "Win32",
|
||||
roles: ["operator"],
|
||||
},
|
||||
],
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "windows-node",
|
||||
displayName: "Windows node",
|
||||
platform: "win32",
|
||||
connected: false,
|
||||
paired: true,
|
||||
},
|
||||
{
|
||||
nodeId: "windows-node-online",
|
||||
displayName: "Online Windows node",
|
||||
platform: "Windows 11",
|
||||
connected: true,
|
||||
paired: true,
|
||||
},
|
||||
{
|
||||
nodeId: "windows-node-pending",
|
||||
displayName: "Pending Windows node",
|
||||
platform: "win32",
|
||||
connected: false,
|
||||
paired: true,
|
||||
approvalState: "pending-approval",
|
||||
pendingRequestId: "pending-windows",
|
||||
},
|
||||
{
|
||||
nodeId: "windows-node-unapproved",
|
||||
displayName: "Unapproved Windows node",
|
||||
platform: "windows",
|
||||
connected: false,
|
||||
paired: true,
|
||||
approvalState: "unapproved",
|
||||
},
|
||||
],
|
||||
});
|
||||
const card = getInventoryCard(container);
|
||||
const wakeChips = Array.from(card.querySelectorAll(".chip")).filter(
|
||||
(chip) => chip.textContent?.trim() === "manual wake required",
|
||||
);
|
||||
|
||||
expect(card.querySelector('[aria-label="offline"]')).not.toBeNull();
|
||||
expect(wakeChips).toHaveLength(1);
|
||||
expect(wakeChips[0]?.getAttribute("title")).toBe(
|
||||
"The Gateway cannot wake an offline Windows node. Start the machine or restore its network connection.",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows token rows with rotate and revoke inside entry details", () => {
|
||||
const rotations: Array<{ deviceId: string; role: string }> = [];
|
||||
const revocations: Array<{ deviceId: string; role: string }> = [];
|
||||
|
||||
@@ -11,6 +11,7 @@ export type NodesProps = {
|
||||
loading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
presence: PresenceEntry[];
|
||||
gatewayVersion: string | null;
|
||||
lastError: string | null;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
|
||||
Reference in New Issue
Block a user