From 2bed3947937cf41eff809eb06153132158ce08b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 04:29:19 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 88 ++++++++++ docs/docs_map.md | 1 + docs/web/control-ui.md | 4 + packages/gateway-protocol/src/index.ts | 10 ++ packages/gateway-protocol/src/schema.ts | 1 + .../src/schema/protocol-schemas.ts | 3 + .../src/schema/system-info.test.ts | 36 ++++ .../src/schema/system-info.ts | 31 ++++ packages/gateway-protocol/src/schema/types.ts | 2 + src/gateway/methods/core-descriptors.ts | 2 + src/gateway/server-methods.ts | 1 + .../server-methods/system-info.test.ts | 46 +++++ src/gateway/server-methods/system.ts | 72 +++++++- taxonomy.yaml | 2 +- ui/src/pages/config/config-page.test.ts | 45 ++++- ui/src/pages/config/config-page.ts | 157 +++++++++++++++++- ui/src/pages/config/quick.test.ts | 63 +++++++ ui/src/pages/config/quick.ts | 62 ++++++- ui/src/styles/config-quick.css | 4 +- 20 files changed, 620 insertions(+), 11 deletions(-) create mode 100644 packages/gateway-protocol/src/schema/system-info.test.ts create mode 100644 packages/gateway-protocol/src/schema/system-info.ts create mode 100644 src/gateway/server-methods/system-info.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ccc4cc4e103..5ef6147522a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index f6e9064e8f1e..9b57ffd81fe2 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/docs/docs_map.md b/docs/docs_map.md index 2ca5f3622a03..3a5c06b36ee5 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9b143d58c58e..3618a60f581a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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). diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index e858f3340d19..25b3d0feeeef 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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( EnvironmentsStatusParamsSchema, ); +export const validateSystemInfoParams = lazyCompile(SystemInfoParamsSchema); +export const validateSystemInfoResult = lazyCompile(SystemInfoResultSchema); export const validateNodePendingAckParams = lazyCompile( 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, diff --git a/packages/gateway-protocol/src/schema.ts b/packages/gateway-protocol/src/schema.ts index 51651bf548de..9fb6d93d75e2 100644 --- a/packages/gateway-protocol/src/schema.ts +++ b/packages/gateway-protocol/src/schema.ts @@ -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"; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 1677949aae29..5b3d08b5aa81 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -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, diff --git a/packages/gateway-protocol/src/schema/system-info.test.ts b/packages/gateway-protocol/src/schema/system-info.test.ts new file mode 100644 index 000000000000..62045e15758e --- /dev/null +++ b/packages/gateway-protocol/src/schema/system-info.test.ts @@ -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); + }); +}); diff --git a/packages/gateway-protocol/src/schema/system-info.ts b/packages/gateway-protocol/src/schema/system-info.ts new file mode 100644 index 000000000000..1e092212adc8 --- /dev/null +++ b/packages/gateway-protocol/src/schema/system-info.ts @@ -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 }, +); diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index d3143aa56d4e..6926d7bb9109 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -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">; diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index d7bb5ab74d88..119737cd4df8 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -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 = new Map( diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 8cf65e74b5a8..59a22561fb53 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -556,6 +556,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { "last-heartbeat", "set-heartbeats", "system-presence", + "system.info", "system-event", ], loadHandlers: loadSystemHandlers, diff --git a/src/gateway/server-methods/system-info.test.ts b/src/gateway/server-methods/system-info.test.ts new file mode 100644 index 000000000000..29b07aa9fecb --- /dev/null +++ b/src/gateway/server-methods/system-info.test.ts @@ -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()), + 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); + }); +}); diff --git a/src/gateway/server-methods/system.ts b/src/gateway/server-methods/system.ts index 3c031b247666..c9b21a3b0182 100644 --- a/src/gateway/server-methods/system.ts +++ b/src/gateway/server-methods/system.ts @@ -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 | null = null; + +function resolveCachedAdvertisedLanHost(): Promise { + // 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 { + 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. diff --git a/taxonomy.yaml b/taxonomy.yaml index ee13cb3f3c87..356588527a87 100644 --- a/taxonomy.yaml +++ b/taxonomy.yaml @@ -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.' diff --git a/ui/src/pages/config/config-page.test.ts b/ui/src/pages/config/config-page.test.ts index eb1a91924ae3..e70e4b010d48 100644 --- a/ui/src/pages/config/config-page.test.ts +++ b/ui/src/pages/config/config-page.test.ts @@ -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(); + }); +}); diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index e399e45a9ef1..eb923e04c9f6 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -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 = { 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 | 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) { + 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), diff --git a/ui/src/pages/config/quick.test.ts b/ui/src/pages/config/quick.test.ts index c109de598faf..0d6b68347d38 100644 --- a/ui/src/pages/config/quick.test.ts +++ b/ui/src/pages/config/quick.test.ts @@ -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"); diff --git a/ui/src/pages/config/quick.ts b/ui/src/pages/config/quick.ts index 6fccf176067b..54f8a05251ad 100644 --- a/ui/src/pages/config/quick.ts +++ b/ui/src/pages/config/quick.ts @@ -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` +
+ ${label} + ${value} +
+ `; +} + +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` +
+ ${renderCardHeader(icons.monitor, "Gateway Host")} +
+ ${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} +
+
+ `; +} + function renderAppearanceCard(props: QuickSettingsProps) { const importedThemeName = props.hasCustomTheme ? (props.customThemeLabel ?? "Imported theme") @@ -996,8 +1054,8 @@ export function renderQuickSettings(props: QuickSettingsProps) {
${renderModelCard(props)} ${renderChannelsCard(props)} ${renderSecurityCard(props)} - ${renderAppearanceCard(props)} ${renderPersonalCard(props)} ${renderAutomationsCard(props)} - ${renderPresetsCard(props)} + ${renderSystemCard(props)} ${renderAppearanceCard(props)} ${renderPersonalCard(props)} + ${renderAutomationsCard(props)} ${renderPresetsCard(props)}
${renderConnectionFooter(props)} diff --git a/ui/src/styles/config-quick.css b/ui/src/styles/config-quick.css index 4a07cf97fd28..356d3a6776c0 100644 --- a/ui/src/styles/config-quick.css +++ b/ui/src/styles/config-quick.css @@ -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; }