mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
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:
parent
c9219c7f80
commit
2bed394793
20 changed files with 620 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
36
packages/gateway-protocol/src/schema/system-info.test.ts
Normal file
36
packages/gateway-protocol/src/schema/system-info.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
packages/gateway-protocol/src/schema/system-info.ts
Normal file
31
packages/gateway-protocol/src/schema/system-info.ts
Normal 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 },
|
||||
);
|
||||
|
|
@ -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">;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -556,6 +556,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
|||
"last-heartbeat",
|
||||
"set-heartbeats",
|
||||
"system-presence",
|
||||
"system.info",
|
||||
"system-event",
|
||||
],
|
||||
loadHandlers: loadSystemHandlers,
|
||||
|
|
|
|||
46
src/gateway/server-methods/system-info.test.ts
Normal file
46
src/gateway/server-methods/system-info.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue