feat(gateway): add system.info RPC and Gateway Host card in Settings (#100478)

* feat(gateway): add system.info RPC and Gateway Host card in Settings

Settings quick page now shows where the Gateway runs and how the host
is doing: machine name, LAN address and runtime port, OS, Node/PID,
uptime, CPU count and load, RAM, and free disk on the state-dir volume.
Backed by a new additive operator.read RPC (system.info); the card
polls every 10s while visible and hides for clients without the read
scope or against older gateways.

Refs #100465

* docs: regenerate docs map for Gateway host status section

* fix(gateway): harden system info compatibility

* fix(ui): clear stale gateway host info

* docs(changelog): note gateway host status
This commit is contained in:
Peter Steinberger 2026-07-06 04:29:19 +01:00 committed by GitHub
parent c9219c7f80
commit 2bed394793
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 620 additions and 11 deletions

View file

@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- **Gateway host status:** show the connected Gateway's host, network address, OS, runtime, uptime, CPU, memory, and disk details in Control UI Settings. (#100478)
- **iOS offline chat:** pre-paint recent sessions and canonical transcripts from a protected, bounded per-gateway cache, keep sending disabled offline, and purge cached conversation text when pairing is reset. (#100194)
- **Slack progress indicators:** use Slack's native assistant thread status and rotating loading messages by default while keeping acknowledgement reactions static; lifecycle reaction updates now require `messages.statusReactions.enabled: true`.
- **Control UI Talk controls:** keep voice, model, and sensitivity in the composer while moving provider, transport, VAD timing, and reasoning defaults to Settings → Communications → Talk.

View file

@ -509,6 +509,94 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
}
}
public struct SystemInfoParams: Codable, Sendable {}
public struct SystemInfoResult: Codable, Sendable {
public let machinename: String
public let hostname: String
public let platform: String
public let release: String
public let arch: String
public let oslabel: String
public let lanaddress: String?
public let port: Int?
public let nodeversion: String
public let pid: Int
public let uptimems: Int
public let cpucount: Int
public let cpumodel: String?
public let loadaverage: [AnyCodable]?
public let memorytotalbytes: Int
public let memoryfreebytes: Int
public let disktotalbytes: Int?
public let diskavailablebytes: Int?
public let diskpath: String?
public init(
machinename: String,
hostname: String,
platform: String,
release: String,
arch: String,
oslabel: String,
lanaddress: String?,
port: Int?,
nodeversion: String,
pid: Int,
uptimems: Int,
cpucount: Int,
cpumodel: String?,
loadaverage: [AnyCodable]?,
memorytotalbytes: Int,
memoryfreebytes: Int,
disktotalbytes: Int?,
diskavailablebytes: Int?,
diskpath: String?)
{
self.machinename = machinename
self.hostname = hostname
self.platform = platform
self.release = release
self.arch = arch
self.oslabel = oslabel
self.lanaddress = lanaddress
self.port = port
self.nodeversion = nodeversion
self.pid = pid
self.uptimems = uptimems
self.cpucount = cpucount
self.cpumodel = cpumodel
self.loadaverage = loadaverage
self.memorytotalbytes = memorytotalbytes
self.memoryfreebytes = memoryfreebytes
self.disktotalbytes = disktotalbytes
self.diskavailablebytes = diskavailablebytes
self.diskpath = diskpath
}
private enum CodingKeys: String, CodingKey {
case machinename = "machineName"
case hostname
case platform
case release
case arch
case oslabel = "osLabel"
case lanaddress = "lanAddress"
case port
case nodeversion = "nodeVersion"
case pid
case uptimems = "uptimeMs"
case cpucount = "cpuCount"
case cpumodel = "cpuModel"
case loadaverage = "loadAverage"
case memorytotalbytes = "memoryTotalBytes"
case memoryfreebytes = "memoryFreeBytes"
case disktotalbytes = "diskTotalBytes"
case diskavailablebytes = "diskAvailableBytes"
case diskpath = "diskPath"
}
}
public struct AgentEvent: Codable, Sendable {
public let runid: String
public let seq: Int

View file

@ -9779,6 +9779,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Pair a mobile device
- H2: Personal identity (browser-local)
- H2: Runtime config endpoint
- H2: Gateway host status
- H2: Language support
- H2: Appearance themes
- H2: Sidebar navigation

View file

@ -92,6 +92,10 @@ The assistant avatar override follows the same browser-local pattern: uploaded o
The Control UI fetches its runtime settings from `/control-ui-config.json`, resolved relative to the gateway's Control UI base path (for example `/__openclaw__/control-ui-config.json` under base path `/__openclaw__/`). That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires a valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
## Gateway host status
Open **Settings** in Simple view to see the **Gateway Host** card with the Gateway machine, LAN address, operating system, runtime, uptime, CPU load, memory, and state-volume disk space. The card refreshes every 10 seconds while visible through the `system.info` Gateway RPC, which requires the `operator.read` scope. Older Gateways and connections without that scope omit the card.
## Language support
The Control UI localizes itself on first load based on your browser locale. To override it later, open **Overview -> Gateway Access -> Language** (the picker lives in the Gateway Access card, not under Appearance).

View file

@ -241,6 +241,10 @@ import {
EnvironmentsStatusResultSchema,
type EnvironmentStatus,
EnvironmentStatusSchema,
type SystemInfoParams,
SystemInfoParamsSchema,
type SystemInfoResult,
SystemInfoResultSchema,
type ErrorShape,
ErrorShapeSchema,
type EventFrame,
@ -658,6 +662,8 @@ export const validateEnvironmentsListParams = lazyCompile<EnvironmentsListParams
export const validateEnvironmentsStatusParams = lazyCompile<EnvironmentsStatusParams>(
EnvironmentsStatusParamsSchema,
);
export const validateSystemInfoParams = lazyCompile<SystemInfoParams>(SystemInfoParamsSchema);
export const validateSystemInfoResult = lazyCompile<SystemInfoResult>(SystemInfoResultSchema);
export const validateNodePendingAckParams = lazyCompile<NodePendingAckParams>(
NodePendingAckParamsSchema,
);
@ -1079,6 +1085,8 @@ export {
EnvironmentsListResultSchema,
EnvironmentsStatusParamsSchema,
EnvironmentsStatusResultSchema,
SystemInfoParamsSchema,
SystemInfoResultSchema,
StateVersionSchema,
AgentEventSchema,
MessageActionParamsSchema,
@ -1464,6 +1472,8 @@ export type {
EnvironmentsListResult,
EnvironmentsStatusParams,
EnvironmentsStatusResult,
SystemInfoParams,
SystemInfoResult,
NodePairRejectParams,
NodePairRemoveParams,
NodePairVerifyParams,

View file

@ -25,6 +25,7 @@ export * from "./schema/push.js";
export * from "./schema/secrets.js";
export * from "./schema/sessions.js";
export * from "./schema/snapshot.js";
export * from "./schema/system-info.js";
export * from "./schema/tasks.js";
export * from "./schema/terminal.js";
export * from "./schema/types.js";

View file

@ -302,6 +302,7 @@ import {
SessionsUsageParamsSchema,
} from "./sessions.js";
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
import { SystemInfoParamsSchema, SystemInfoResultSchema } from "./system-info.js";
import {
TasksCancelParamsSchema,
TasksCancelResultSchema,
@ -360,6 +361,8 @@ export const ProtocolSchemas = {
EnvironmentsListResult: EnvironmentsListResultSchema,
EnvironmentsStatusParams: EnvironmentsStatusParamsSchema,
EnvironmentsStatusResult: EnvironmentsStatusResultSchema,
SystemInfoParams: SystemInfoParamsSchema,
SystemInfoResult: SystemInfoResultSchema,
AgentEvent: AgentEventSchema,
MessageActionParams: MessageActionParamsSchema,
SendParams: SendParamsSchema,

View file

@ -0,0 +1,36 @@
// Gateway Protocol tests cover strict Gateway host system information payloads.
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import { SystemInfoResultSchema } from "./system-info.js";
const validSystemInfo = {
machineName: "Gateway Mac",
hostname: "gateway.local",
platform: "darwin",
release: "25.5.0",
arch: "arm64",
osLabel: "macOS 26.5.0",
lanAddress: "192.168.1.20",
port: 18789,
nodeVersion: "v24.1.0",
pid: 1234,
uptimeMs: 60_000,
cpuCount: 10,
cpuModel: "Apple M4",
loadAverage: [1.2, 1.1, 0.9],
memoryTotalBytes: 34_359_738_368,
memoryFreeBytes: 17_179_869_184,
diskTotalBytes: 994_662_584_320,
diskAvailableBytes: 497_331_292_160,
diskPath: "/Users/operator/.openclaw",
};
describe("SystemInfoResultSchema", () => {
it("accepts a complete Gateway host snapshot", () => {
expect(Value.Check(SystemInfoResultSchema, validSystemInfo)).toBe(true);
});
it("rejects extra properties", () => {
expect(Value.Check(SystemInfoResultSchema, { ...validSystemInfo, extra: true })).toBe(false);
});
});

View file

@ -0,0 +1,31 @@
// Gateway Protocol schema module defines Gateway host system information.
import { Type } from "typebox";
/** Empty request payload for Gateway host system information. */
export const SystemInfoParamsSchema = Type.Object({}, { additionalProperties: false });
/** Gateway host identity and resource snapshot. */
export const SystemInfoResultSchema = Type.Object(
{
machineName: Type.String(),
hostname: Type.String(),
platform: Type.String(),
release: Type.String(),
arch: Type.String(),
osLabel: Type.String(),
lanAddress: Type.Optional(Type.String()),
port: Type.Optional(Type.Integer()),
nodeVersion: Type.String(),
pid: Type.Integer(),
uptimeMs: Type.Integer(),
cpuCount: Type.Integer(),
cpuModel: Type.Optional(Type.String()),
loadAverage: Type.Optional(Type.Tuple([Type.Number(), Type.Number(), Type.Number()])),
memoryTotalBytes: Type.Integer(),
memoryFreeBytes: Type.Integer(),
diskTotalBytes: Type.Optional(Type.Integer()),
diskAvailableBytes: Type.Optional(Type.Integer()),
diskPath: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);

View file

@ -31,6 +31,8 @@ export type EnvironmentsListParams = SchemaType<"EnvironmentsListParams">;
export type EnvironmentsListResult = SchemaType<"EnvironmentsListResult">;
export type EnvironmentsStatusParams = SchemaType<"EnvironmentsStatusParams">;
export type EnvironmentsStatusResult = SchemaType<"EnvironmentsStatusResult">;
export type SystemInfoParams = SchemaType<"SystemInfoParams">;
export type SystemInfoResult = SchemaType<"SystemInfoResult">;
/** Agent activity, identity, send, poll, wait, and wake protocol payloads. */
export type AgentEvent = SchemaType<"AgentEvent">;

View file

@ -246,6 +246,8 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "terminal.list", scope: "operator.admin" },
{ name: "terminal.text", scope: "operator.admin" },
{ name: "controlUi.githubPreview", scope: "operator.read" },
// Additive discovery methods append here so older clients keep stable indices.
{ name: "system.info", scope: "operator.read" },
] as const;
const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap<string, CoreGatewayMethodSpec> = new Map(

View file

@ -556,6 +556,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
"last-heartbeat",
"set-heartbeats",
"system-presence",
"system.info",
"system-event",
],
loadHandlers: loadSystemHandlers,

View file

@ -0,0 +1,46 @@
/** Gateway system.info method tests. */
import { describe, expect, it, vi } from "vitest";
import { validateSystemInfoResult } from "../../../packages/gateway-protocol/src/index.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const mocks = vi.hoisted(() => ({
resolveAdvertisedLanHost: vi.fn(async () => "192.168.1.20"),
}));
// Keep every real export available: other modules in the import graph may pull
// parse/select helpers from this module, and a partial factory would break them.
vi.mock("../../infra/advertised-lan-host.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../infra/advertised-lan-host.js")>()),
resolveAdvertisedLanHost: mocks.resolveAdvertisedLanHost,
}));
import { systemHandlers } from "./system.js";
describe("system.info", () => {
it("returns a schema-valid host resource snapshot", async () => {
const respond = vi.fn();
const request = {
params: {},
respond,
context: {
getRuntimeConfig: () => ({ gateway: { port: 18789 } }),
},
} as unknown as GatewayRequestHandlerOptions;
await systemHandlers["system.info"](request);
await systemHandlers["system.info"](request);
expect(respond).toHaveBeenCalledTimes(2);
expect(mocks.resolveAdvertisedLanHost).toHaveBeenCalledTimes(1);
const [ok, payload, error] = respond.mock.calls[0] ?? [];
expect(ok).toBe(true);
expect(error).toBeUndefined();
if (!validateSystemInfoResult(payload)) {
throw new Error("system.info returned an invalid payload");
}
expect(payload.cpuCount).toBeGreaterThanOrEqual(1);
expect(payload.memoryTotalBytes).toBeGreaterThan(0);
expect(payload.uptimeMs).toBeGreaterThanOrEqual(0);
});
});

View file

@ -1,24 +1,82 @@
// System gateway methods expose device identity, heartbeat controls, system
// System gateway methods expose device and host identity, heartbeat controls,
// presence snapshots, and normalized system events.
import os from "node:os";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import {
ErrorCodes,
errorShape,
type SystemInfoResult,
validateSystemInfoParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveGatewayPort, resolveStateDir } from "../../config/paths.js";
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
import { resolveAdvertisedLanHost } from "../../infra/advertised-lan-host.js";
import {
loadOrCreateProcessDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../../infra/device-identity.js";
import { tryReadDiskSpace } from "../../infra/disk-space.js";
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { resolveRuntimeOsLabel } from "../../infra/os-summary.js";
import { enqueueSystemEvent, isSystemEventContextChanged } from "../../infra/system-events.js";
import { listSystemPresence, updateSystemPresence } from "../../infra/system-presence.js";
import { broadcastPresenceSnapshot } from "../server/presence-events.js";
import type { GatewayRequestHandlers } from "./types.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
/** Gateway handlers for identity, heartbeat toggles, and system presence events. */
let advertisedLanHostPromise: Promise<string | null> | null = null;
function resolveCachedAdvertisedLanHost(): Promise<string | null> {
// Route discovery may spawn a platform command. Keep the result process-stable
// so each visible Settings page does not repeat that work every ten seconds.
advertisedLanHostPromise ??= resolveAdvertisedLanHost().catch(() => null);
return advertisedLanHostPromise;
}
async function collectSystemInfo(context: GatewayRequestContext): Promise<SystemInfoResult> {
const cpus = os.cpus();
const cpuModel = cpus[0]?.model.trim() || undefined;
const [oneMinute = 0, fiveMinutes = 0, fifteenMinutes = 0] = os.loadavg();
const loadAverage: [number, number, number] = [oneMinute, fiveMinutes, fifteenMinutes];
const stateDir = resolveStateDir();
const disk = tryReadDiskSpace(stateDir);
const port = resolveGatewayPort(context.getRuntimeConfig());
const lanAddress = (await resolveCachedAdvertisedLanHost()) ?? undefined;
return {
machineName: await getMachineDisplayName(),
hostname: os.hostname(),
platform: os.platform(),
release: os.release(),
arch: os.arch(),
osLabel: resolveRuntimeOsLabel(),
...(lanAddress ? { lanAddress } : {}),
port,
nodeVersion: process.version,
pid: process.pid,
uptimeMs: Math.round(process.uptime() * 1000),
cpuCount: cpus.length,
...(cpuModel ? { cpuModel } : {}),
...(loadAverage.some((value) => value !== 0) ? { loadAverage } : {}),
memoryTotalBytes: os.totalmem(),
memoryFreeBytes: os.freemem(),
...(disk?.totalBytes != null
? {
diskTotalBytes: disk.totalBytes,
diskAvailableBytes: disk.availableBytes,
diskPath: stateDir,
}
: {}),
};
}
/** Gateway handlers for identity, host information, heartbeat toggles, and presence events. */
export const systemHandlers: GatewayRequestHandlers = {
"gateway.identity.get": ({ respond }) => {
const identity = loadOrCreateProcessDeviceIdentity();
@ -54,6 +112,12 @@ export const systemHandlers: GatewayRequestHandlers = {
const presence = listSystemPresence();
respond(true, presence, undefined);
},
"system.info": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateSystemInfoParams, "system.info", respond)) {
return;
}
respond(true, await collectSystemInfo(context), undefined);
},
"system-event": ({ params, respond, context }) => {
// System events come from mixed RPC clients; normalize fields before
// presence state decides whether this event should fan out or be elided.

View file

@ -357,7 +357,7 @@ surfaces:
description: '`health` and `status` RPCs.'
- name: Identity and presence APIs
coverageIds: [gateway.identity-and-presence-apis]
description: '`gateway.identity.get`, `system-presence`, `system-event`, and heartbeat RPCs.'
description: '`gateway.identity.get`, `system.info`, `system-presence`, `system-event`, and heartbeat RPCs.'
- name: Model APIs
coverageIds: [gateway.model-apis]
description: '`models.list` RPCs.'

View file

@ -1,7 +1,10 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from "vitest";
import { configSelectionFromSearch } from "./config-page.ts";
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
import { ConfigPage, configSelectionFromSearch, supportsSystemInfo } from "./config-page.ts";
describe("configSelectionFromSearch", () => {
it("opens a valid linked Settings section", () => {
@ -18,3 +21,43 @@ describe("configSelectionFromSearch", () => {
});
});
});
describe("supportsSystemInfo", () => {
it("requires the Gateway to advertise system.info", () => {
const hello = {
features: { methods: ["health", "system.info"] },
} as ApplicationGatewaySnapshot["hello"];
const unsupportedHello = {
features: { methods: ["health"] },
} as ApplicationGatewaySnapshot["hello"];
expect(supportsSystemInfo(hello)).toBe(true);
expect(supportsSystemInfo(unsupportedHello)).toBe(false);
expect(supportsSystemInfo(null)).toBe(false);
});
});
describe("ConfigPage system info", () => {
it("clears stale host info when the Gateway disconnects", () => {
const client = {} as GatewayBrowserClient;
const snapshot = {
client,
connected: false,
hello: null,
} as ApplicationGatewaySnapshot;
const page = new ConfigPage();
const state = page as unknown as {
context: { gateway: { snapshot: ApplicationGatewaySnapshot } };
systemInfo: SystemInfoResult | null;
systemInfoClient: GatewayBrowserClient | null;
handleSystemInfoGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot) => void;
};
state.context = { gateway: { snapshot } };
state.systemInfoClient = client;
state.systemInfo = {} as SystemInfoResult;
state.handleSystemInfoGatewaySnapshot(snapshot);
expect(state.systemInfo).toBeNull();
});
});

View file

@ -1,9 +1,15 @@
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
import type { FastMode } from "../../api/types.ts";
import type { RouteId } from "../../app-route-paths.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import {
applicationContext,
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { importCustomThemeFromUrl } from "../../app/custom-theme.ts";
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
import {
@ -16,6 +22,7 @@ import { startThemeTransition } from "../../app/theme-transition.ts";
import { resolveTheme, type ThemeMode, type ThemeName } from "../../app/theme.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { isMissingOperatorReadScopeError } from "../../lib/gateway-errors.ts";
import { renderMcp } from "./mcp.ts";
import { getPresetById } from "./presets.ts";
import {
@ -98,6 +105,19 @@ const KNOWN_CHANNELS = [
] as const;
const BASE_RADII = { sm: 6, md: 10, lg: 14, xl: 20, full: 9999, default: 10 };
const SYSTEM_INFO_POLL_INTERVAL_MS = 10_000;
function isUnknownSystemInfoMethodError(error: unknown): boolean {
return (
error instanceof GatewayRequestError &&
error.gatewayCode === "INVALID_REQUEST" &&
error.message.includes("unknown method: system.info")
);
}
export function supportsSystemInfo(hello: ApplicationGatewaySnapshot["hello"]): boolean {
return hello?.features?.methods?.includes("system.info") === true;
}
function defaultConfigSelection(pageId: ConfigPageId): ConfigSelection {
switch (pageId) {
@ -271,6 +291,8 @@ export class ConfigPage extends LitElement {
@state() private settings = loadSettings();
@state() private settingsMode: "quick" | "advanced" = "quick";
@state() private systemInfo: SystemInfoResult | null = null;
@state() private systemInfoUnavailable = false;
@state() private formModes: Record<ConfigPageId, ConfigFormMode> = {
config: "form",
communications: "form",
@ -306,6 +328,10 @@ export class ConfigPage extends LitElement {
@state() private customThemeImportFocusToken = 0;
private customThemeImportSelectOnSuccess = false;
private readonly configViewState: ConfigViewState = createConfigViewState();
private systemInfoClient: GatewayBrowserClient | null = null;
private systemInfoLoading = false;
private systemInfoRequestId = 0;
private systemInfoPollInterval: ReturnType<typeof globalThis.setInterval> | null = null;
private stops: Array<() => void> = [];
override createRenderRoot() {
@ -324,12 +350,16 @@ export class ConfigPage extends LitElement {
this.context.runtimeConfig.subscribe(() => this.requestUpdate()),
this.context.overlays.subscribe(() => this.requestUpdate()),
this.context.config.subscribe(() => this.requestUpdate()),
this.context.gateway.subscribe(() => this.requestUpdate()),
this.context.gateway.subscribe((snapshot) => {
this.handleSystemInfoGatewaySnapshot(snapshot);
this.requestUpdate();
}),
this.context.webPush.subscribe(() => this.requestUpdate()),
this.context.theme.subscribe(() => {
this.settings = loadSettings();
}),
];
this.handleSystemInfoGatewaySnapshot(this.context.gateway.snapshot);
const config = this.context.runtimeConfig.state;
if (!config.configSnapshot && !config.configLoading) {
void this.context.runtimeConfig
@ -341,6 +371,9 @@ export class ConfigPage extends LitElement {
}
override disconnectedCallback() {
this.stopSystemInfoPolling();
this.invalidateSystemInfoRequest();
this.systemInfoClient = null;
for (const stop of this.stops) {
stop();
}
@ -348,6 +381,124 @@ export class ConfigPage extends LitElement {
super.disconnectedCallback();
}
override updated(changed: Map<PropertyKey, unknown>) {
const pageChanged = changed.has("pageId") && changed.get("pageId") !== undefined;
const modeChanged = changed.has("settingsMode") && changed.get("settingsMode") !== undefined;
if (pageChanged || modeChanged) {
this.invalidateSystemInfoRequest();
}
this.syncSystemInfoPolling();
}
private isSystemInfoVisible(): boolean {
return this.pageId === "config" && this.settingsMode === "quick";
}
private handleSystemInfoGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) {
const clientChanged = snapshot.client !== this.systemInfoClient;
const hasSystemInfo = supportsSystemInfo(snapshot.hello);
this.systemInfoClient = snapshot.client;
if (clientChanged) {
this.invalidateSystemInfoRequest();
this.systemInfo = null;
this.systemInfoUnavailable = false;
} else if (!snapshot.connected) {
this.invalidateSystemInfoRequest();
this.systemInfo = null;
}
if (snapshot.connected && snapshot.hello) {
this.systemInfoUnavailable = !hasSystemInfo;
if (!hasSystemInfo) {
this.invalidateSystemInfoRequest();
this.systemInfo = null;
}
}
this.syncSystemInfoPolling();
}
private syncSystemInfoPolling() {
const gateway = this.context.gateway.snapshot;
const shouldPoll =
this.isConnected &&
this.isSystemInfoVisible() &&
!this.systemInfoUnavailable &&
gateway.connected &&
supportsSystemInfo(gateway.hello) &&
gateway.client != null;
if (!shouldPoll) {
this.stopSystemInfoPolling();
return;
}
if (this.systemInfoPollInterval !== null) {
return;
}
void this.loadSystemInfo();
this.systemInfoPollInterval = globalThis.setInterval(() => {
void this.loadSystemInfo();
}, SYSTEM_INFO_POLL_INTERVAL_MS);
}
private stopSystemInfoPolling() {
if (this.systemInfoPollInterval === null) {
return;
}
globalThis.clearInterval(this.systemInfoPollInterval);
this.systemInfoPollInterval = null;
}
private invalidateSystemInfoRequest() {
this.systemInfoRequestId += 1;
this.systemInfoLoading = false;
}
private isCurrentSystemInfoRequest(requestId: number, client: GatewayBrowserClient): boolean {
const gateway = this.context.gateway.snapshot;
return (
this.isConnected &&
this.isSystemInfoVisible() &&
requestId === this.systemInfoRequestId &&
gateway.connected &&
gateway.client === client
);
}
private async loadSystemInfo() {
const gateway = this.context.gateway.snapshot;
const client = gateway.client;
if (
!gateway.connected ||
!client ||
!this.isSystemInfoVisible() ||
this.systemInfoUnavailable ||
this.systemInfoLoading
) {
return;
}
const requestId = ++this.systemInfoRequestId;
this.systemInfoLoading = true;
try {
const response = await client.request("system.info", {});
if (!this.isCurrentSystemInfoRequest(requestId, client)) {
return;
}
this.systemInfo = response as SystemInfoResult;
} catch (error) {
if (!this.isCurrentSystemInfoRequest(requestId, client)) {
return;
}
if (isMissingOperatorReadScopeError(error) || isUnknownSystemInfoMethodError(error)) {
this.systemInfo = null;
this.systemInfoUnavailable = true;
this.stopSystemInfoPolling();
}
} finally {
if (this.isCurrentSystemInfoRequest(requestId, client)) {
this.systemInfoLoading = false;
}
}
}
private navigate(routeId: RouteId) {
this.context.navigate(routeId);
}
@ -634,6 +785,8 @@ export class ConfigPage extends LitElement {
mcpServerCount: mcpServerCount(configObject),
},
security: extractQuickSettingsSecurity(configObject),
systemInfo: this.systemInfo,
systemInfoUnavailable: this.systemInfoUnavailable,
theme: this.settings.theme,
themeMode: this.settings.themeMode,
hasCustomTheme: Boolean(this.settings.customTheme),

View file

@ -126,6 +126,7 @@ describe("renderQuickSettings", () => {
"qs-card--model",
"qs-card--channels",
"qs-card--security",
"qs-card--system",
"qs-card--appearance",
"qs-card--personal",
"qs-card--automations",
@ -133,6 +134,68 @@ describe("renderQuickSettings", () => {
expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1);
});
it("renders Gateway host identity and resources", () => {
const container = document.createElement("div");
render(
renderQuickSettings(
createProps({
systemInfo: {
machineName: "Gateway Mac",
hostname: "gateway.local",
platform: "darwin",
release: "25.5.0",
arch: "arm64",
osLabel: "macOS 26.5.0",
lanAddress: "192.168.1.20",
port: 18789,
nodeVersion: "v24.1.0",
pid: 1234,
uptimeMs: 3_600_000,
cpuCount: 10,
cpuModel: "Apple M4",
loadAverage: [1.2, 1.1, 0.9],
memoryTotalBytes: 34_359_738_368,
memoryFreeBytes: 17_179_869_184,
diskTotalBytes: 994_662_584_320,
diskAvailableBytes: 497_331_292_160,
diskPath: "/Users/operator/.openclaw",
},
}),
),
container,
);
const hostRow = expectRowByLabel(container, "Host");
expect(hostRow.querySelector(".qs-row__value")?.textContent).toBe("Gateway Mac");
expect(hostRow.querySelector(".qs-row__value")?.getAttribute("title")).toBe("gateway.local");
expect(expectRowByLabel(container, "Address").textContent).toContain("192.168.1.20:18789");
expect(expectRowByLabel(container, "OS").textContent).toContain("macOS 26.5.0 · arm64");
expect(expectRowByLabel(container, "Uptime").textContent).toContain("1h");
expect(expectRowByLabel(container, "CPU").textContent).toContain("10 cores · load 1.2");
expect(expectRowByLabel(container, "Memory").textContent).toContain("16 GB free of 32 GB");
expect(expectRowByLabel(container, "Disk").textContent).toContain("463 GB free of 926 GB");
});
it("hides Gateway host details when the RPC is unavailable", () => {
const container = document.createElement("div");
render(renderQuickSettings(createProps({ systemInfoUnavailable: true })), container);
expect(container.querySelector(".qs-card--system")).toBeNull();
});
it("reserves the Gateway host card while its first snapshot loads", () => {
const container = document.createElement("div");
render(renderQuickSettings(createProps()), container);
const systemCard = container.querySelector(".qs-card--system");
expect(systemCard).not.toBeNull();
expect(expectRowByLabel(systemCard ?? container, "Host").textContent).toContain("—");
expect(expectRowByLabel(systemCard ?? container, "Disk").textContent).toContain("—");
});
it("shows the current bootstrap default when config omits the explicit limit", () => {
const container = document.createElement("div");

View file

@ -6,6 +6,7 @@
*/
import { html, nothing, type TemplateResult } from "lit";
import type { SystemInfoResult } from "../../../../packages/gateway-protocol/src/index.js";
import { formatFastModeValue } from "../../../../src/shared/fast-mode.js";
import type { FastMode } from "../../api/types.ts";
import { controlUiPublicAssetPath } from "../../app/public-assets.ts";
@ -19,7 +20,9 @@ import {
} from "../../app/user-identity.ts";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import { formatBytes } from "../../lib/agents/display.ts";
import { resolveAssistantTextAvatar, resolveChatAvatarRenderUrl } from "../../lib/avatar.ts";
import { formatDurationHuman } from "../../lib/format.ts";
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
import {
CONFIG_PRESETS,
@ -78,6 +81,10 @@ export type QuickSettingsProps = {
onBrowserEnabledToggle?: (enabled: boolean) => void;
onToolProfileChange?: (profile: string) => void;
// Gateway host
systemInfo?: SystemInfoResult | null;
systemInfoUnavailable?: boolean;
// Appearance
theme: ThemeName;
themeMode: ThemeMode;
@ -590,6 +597,57 @@ function renderSecurityCard(props: QuickSettingsProps) {
`;
}
function renderSystemRow(label: string, value: string, title?: string) {
return html`
<div class="qs-row">
<span class="qs-row__label">${label}</span>
<span class="qs-row__value" title=${title ?? ""}>${value}</span>
</div>
`;
}
function renderSystemCard(props: QuickSettingsProps) {
if (props.systemInfoUnavailable) {
return nothing;
}
const info = props.systemInfo;
const placeholder = "—";
const hostTitle = info && info.hostname !== info.machineName ? info.hostname : undefined;
const address = info?.lanAddress
? `${info.lanAddress}${info.port == null ? "" : `:${info.port}`}`
: placeholder;
const osLabel = info ? `${info.osLabel} · ${info.arch}` : placeholder;
const runtime = info ? `Node ${info.nodeVersion} · PID ${info.pid}` : placeholder;
const cpu = info
? `${info.cpuCount} cores${info.loadAverage ? ` · load ${info.loadAverage[0].toFixed(1)}` : ""}`
: placeholder;
const loadTitle = info?.loadAverage
? `Load average: ${info.loadAverage.map((value) => value.toFixed(1)).join(" · ")}`
: undefined;
const cpuTitle = [info?.cpuModel, loadTitle].filter(Boolean).join(" · ") || undefined;
const memory = info
? `${formatBytes(info.memoryFreeBytes)} free of ${formatBytes(info.memoryTotalBytes)}`
: placeholder;
const hasDisk = info?.diskAvailableBytes != null && info.diskTotalBytes != null;
const disk = hasDisk
? `${formatBytes(info.diskAvailableBytes)} free of ${formatBytes(info.diskTotalBytes)}`
: placeholder;
return html`
<div class="qs-card qs-card--system">
${renderCardHeader(icons.monitor, "Gateway Host")}
<div class="qs-card__body">
${renderSystemRow("Host", info?.machineName ?? placeholder, hostTitle)}
${renderSystemRow("Address", address)} ${renderSystemRow("OS", osLabel)}
${renderSystemRow("Runtime", runtime)}
${renderSystemRow("Uptime", info ? formatDurationHuman(info.uptimeMs) : placeholder)}
${renderSystemRow("CPU", cpu, cpuTitle)} ${renderSystemRow("Memory", memory)}
${info == null || hasDisk ? renderSystemRow("Disk", disk, info?.diskPath) : nothing}
</div>
</div>
`;
}
function renderAppearanceCard(props: QuickSettingsProps) {
const importedThemeName = props.hasCustomTheme
? (props.customThemeLabel ?? "Imported theme")
@ -996,8 +1054,8 @@ export function renderQuickSettings(props: QuickSettingsProps) {
<div class="qs-container">
<div class="qs-grid">
${renderModelCard(props)} ${renderChannelsCard(props)} ${renderSecurityCard(props)}
${renderAppearanceCard(props)} ${renderPersonalCard(props)} ${renderAutomationsCard(props)}
${renderPresetsCard(props)}
${renderSystemCard(props)} ${renderAppearanceCard(props)} ${renderPersonalCard(props)}
${renderAutomationsCard(props)} ${renderPresetsCard(props)}
</div>
${renderConnectionFooter(props)}

View file

@ -212,7 +212,8 @@ openclaw-logs-page {
grid-column: span 4;
}
.qs-card--appearance {
.qs-card--appearance,
.qs-card--system {
grid-column: 1 / -1;
}
@ -1092,6 +1093,7 @@ openclaw-logs-page {
.qs-card--appearance,
.qs-card--personal,
.qs-card--system,
.qs-card--span-all {
grid-column: 1 / -1;
}