From e37e4da2e8ae29e68f62259f8adbd5f0fe090652 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 15:28:46 -0700 Subject: [PATCH] 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 --- ui/src/lib/nodes/inventory.test.ts | 4 + ui/src/lib/nodes/inventory.ts | 4 + ui/src/pages/nodes/nodes-page.ts | 5 + ui/src/pages/nodes/view-inventory.ts | 56 +++++++++++- ui/src/pages/nodes/view.devices.test.ts | 117 ++++++++++++++++++++++++ ui/src/pages/nodes/view.types.ts | 1 + 6 files changed, 185 insertions(+), 2 deletions(-) diff --git a/ui/src/lib/nodes/inventory.test.ts b/ui/src/lib/nodes/inventory.test.ts index 906984c9d213..83048e33d47c 100644 --- a/ui/src/lib/nodes/inventory.test.ts +++ b/ui/src/lib/nodes/inventory.test.ts @@ -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", () => { diff --git a/ui/src/lib/nodes/inventory.ts b/ui/src/lib/nodes/inventory.ts index 575af0762ff3..8c93fe114a1b 100644 --- a/ui/src/lib/nodes/inventory.ts +++ b/ui/src/lib/nodes/inventory.ts @@ -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): 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), diff --git a/ui/src/pages/nodes/nodes-page.ts b/ui/src/pages/nodes/nodes-page.ts index 71af1d95bb26..1f3d6b8ea040 100644 --- a/ui/src/pages/nodes/nodes-page.ts +++ b/ui/src/pages/nodes/nodes-page.ts @@ -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`
@@ -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, diff --git a/ui/src/pages/nodes/view-inventory.ts b/ui/src/pages/nodes/view-inventory.ts index 4efcac6e6e8d..10fd19906a8c 100644 --- a/ui/src/pages/nodes/view-inventory.ts +++ b/ui/src/pages/nodes/view-inventory.ts @@ -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`${role}`); @@ -141,6 +176,23 @@ function entryStatusChips(entry: NodesInventoryEntry): TemplateResult[] { if (entry.autoApproved) { chips.push(html`auto-paired`); } + 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`version drift`); + } + 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`manual wake required`); + } const approvalState = entry.node?.approvalState; if (approvalState === "pending-approval" || approvalState === "pending-reapproval") { chips.push(html`approval needed`); @@ -257,7 +309,7 @@ function renderInventoryEntry(entry: NodesInventoryEntry, props: NodesProps) { title=${entry.connected ? "connected" : "offline"} > ${entry.name} - ${entryStatusChips(entry)} + ${entryStatusChips(entry, props.gatewayVersion)}
${entryMetaLine(entry)}
${renderEntryDetails(entry, props)} diff --git a/ui/src/pages/nodes/view.devices.test.ts b/ui/src/pages/nodes/view.devices.test.ts index 14ff9e7c85f0..07780b28949d 100644 --- a/ui/src/pages/nodes/view.devices.test.ts +++ b/ui/src/pages/nodes/view.devices.test.ts @@ -9,6 +9,7 @@ function baseProps(overrides: Partial = {}): 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 }> = []; diff --git a/ui/src/pages/nodes/view.types.ts b/ui/src/pages/nodes/view.types.ts index 0682cd4bcbf7..7ec3b3857b46 100644 --- a/ui/src/pages/nodes/view.types.ts +++ b/ui/src/pages/nodes/view.types.ts @@ -11,6 +11,7 @@ export type NodesProps = { loading: boolean; nodes: Array>; presence: PresenceEntry[]; + gatewayVersion: string | null; lastError: string | null; devicesLoading: boolean; devicesError: string | null;