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:
Peter Steinberger
2026-07-11 15:28:46 -07:00
committed by GitHub
parent 1b1cebfe42
commit e37e4da2e8
6 changed files with 185 additions and 2 deletions
+4
View File
@@ -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", () => {
+4
View File
@@ -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),
+5
View File
@@ -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,
+54 -2
View File
@@ -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)}
+117
View File
@@ -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 }> = [];
+1
View File
@@ -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;