diff --git a/ui/src/pages/config/quick.test.ts b/ui/src/pages/config/quick.test.ts index ec89709ca59..32265fb291b 100644 --- a/ui/src/pages/config/quick.test.ts +++ b/ui/src/pages/config/quick.test.ts @@ -31,6 +31,16 @@ function expectFileInput(input: Element | null | undefined): HTMLInputElement { return input; } +function expectStatByLabel(container: Element, text: string): HTMLElement { + const stat = Array.from(container.querySelectorAll(".qs-stat")).find( + (candidate) => candidate.querySelector(".qs-stat__label")?.textContent?.trim() === text, + ); + if (!(stat instanceof HTMLElement)) { + throw new Error(`Expected system stat "${text}"`); + } + return stat; +} + function createProps(overrides: Partial = {}): QuickSettingsProps { return { currentModel: "gpt-5.5", @@ -164,15 +174,82 @@ describe("renderQuickSettings", () => { 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"); + const name = container.querySelector(".qs-system__name"); + expect(name?.textContent?.trim()).toBe("Gateway Mac"); + expect(name?.getAttribute("title")).toBe("gateway.local"); + expect(container.querySelector(".qs-system__address")?.textContent?.trim()).toBe( + "192.168.1.20:18789", + ); + const metas = Array.from(container.querySelectorAll(".qs-system__meta")).map((node) => + node.textContent?.trim(), + ); + expect(metas).toEqual(["macOS 26.5.0 · arm64", "Node v24.1.0 · PID 1234"]); + expect( + container.querySelector(".qs-card--system .qs-card__header .qs-badge")?.textContent?.trim(), + ).toBe("Up 1h"); + + const cpu = expectStatByLabel(container, "CPU"); + expect(cpu.querySelector(".qs-stat__value")?.textContent?.replace(/\s+/g, " ").trim()).toBe( + "1.2 load", + ); + expect(cpu.querySelector(".qs-stat__detail")?.textContent?.trim()).toBe("10 cores"); + expect(cpu.getAttribute("title")).toBe("Apple M4 · Load average: 1.2 · 1.1 · 0.9"); + expect(cpu.querySelector(".qs-meter")?.getAttribute("aria-valuenow")).toBe("12"); + + const memory = expectStatByLabel(container, "Memory"); + expect(memory.querySelector(".qs-stat__value")?.textContent?.replace(/\s+/g, " ").trim()).toBe( + "50% used", + ); + expect(memory.querySelector(".qs-stat__detail")?.textContent?.trim()).toBe( + "16 GB free of 32 GB", + ); + + const disk = expectStatByLabel(container, "Disk"); + expect(disk.querySelector(".qs-stat__value")?.textContent?.replace(/\s+/g, " ").trim()).toBe( + "50% used", + ); + expect(disk.querySelector(".qs-stat__detail")?.textContent?.trim()).toBe( + "463 GB free of 926 GB", + ); + expect(disk.getAttribute("title")).toBe("/Users/operator/.openclaw"); + for (const fill of container.querySelectorAll(".qs-meter__fill")) { + expect([...fill.classList]).toContain("qs-meter__fill--ok"); + } + }); + + it("escalates meter tones as resources run hot", () => { + 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", + nodeVersion: "v24.1.0", + pid: 1234, + uptimeMs: 60_000, + cpuCount: 10, + loadAverage: [9.8, 9.1, 8.4], + memoryTotalBytes: 34_359_738_368, + memoryFreeBytes: 2_147_483_648, + diskTotalBytes: 994_662_584_320, + diskAvailableBytes: 198_932_516_864, + }, + }), + ), + container, + ); + + const tone = (label: string) => + expectStatByLabel(container, label).querySelector(".qs-meter__fill")?.classList[1]; + expect(tone("CPU")).toBe("qs-meter__fill--critical"); + expect(tone("Memory")).toBe("qs-meter__fill--critical"); + expect(tone("Disk")).toBe("qs-meter__fill--warn"); }); it("hides Gateway host details when the RPC is unavailable", () => { @@ -190,8 +267,13 @@ describe("renderQuickSettings", () => { 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("—"); + expect(systemCard?.querySelector(".qs-system__name")?.textContent).toContain("—"); + for (const label of ["CPU", "Memory", "Disk"]) { + const stat = expectStatByLabel(systemCard ?? container, label); + expect(stat.querySelector(".qs-stat__value")?.textContent).toContain("—"); + expect(stat.querySelector(".qs-meter")).toBeNull(); + } + expect(systemCard?.querySelector(".qs-system__address")).toBeNull(); }); it("shows the current bootstrap default when config omits the explicit limit", () => { diff --git a/ui/src/pages/config/quick.ts b/ui/src/pages/config/quick.ts index 4c9d103f46c..358a97cef9d 100644 --- a/ui/src/pages/config/quick.ts +++ b/ui/src/pages/config/quick.ts @@ -587,15 +587,120 @@ function renderSecurityCard(props: QuickSettingsProps) { `; } -function renderSystemRow(label: string, value: string, title?: string) { +type SystemStat = { + label: string; + value: string; + unit?: string; + detail?: string; + /** Used share of the resource (0..1); renders the meter bar when present. */ + usedFraction?: number; + title?: string; +}; + +// Meter tones reuse the badge palette: calm until 75%, warn to 92%, critical beyond. +function systemMeterTone(fraction: number): "ok" | "warn" | "critical" { + if (fraction >= 0.92) { + return "critical"; + } + if (fraction >= 0.75) { + return "warn"; + } + return "ok"; +} + +function renderSystemMeter(label: string, fraction: number) { + const clamped = Math.min(Math.max(fraction, 0), 1); + const percent = Math.round(clamped * 100); return html` -
- ${label} - ${value} +
+
`; } +function renderSystemStat(stat: SystemStat) { + return html` +
+
${stat.label}
+
+ ${stat.value}${stat.unit ? html` ${stat.unit}` : nothing} +
+ ${stat.usedFraction == null ? nothing : renderSystemMeter(stat.label, stat.usedFraction)} + ${stat.detail ? html`
${stat.detail}
` : nothing} +
+ `; +} + +function usedFraction(totalBytes: number | undefined, freeBytes: number | undefined) { + if (totalBytes == null || freeBytes == null || totalBytes <= 0) { + return undefined; + } + return (totalBytes - freeBytes) / totalBytes; +} + +function formatUsedPercent(fraction: number) { + return `${Math.round(Math.min(Math.max(fraction, 0), 1) * 100)}%`; +} + +function buildSystemStats(info: SystemInfoResult): SystemStat[] { + const load = info.loadAverage?.[0]; + 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 coresLabel = `${info.cpuCount} core${info.cpuCount === 1 ? "" : "s"}`; + const cpu: SystemStat = + load == null + ? { label: "CPU", value: coresLabel, detail: info.cpuModel, title: cpuTitle } + : { + label: "CPU", + value: load.toFixed(1), + unit: "load", + detail: coresLabel, + // 1-minute load over core count approximates saturation; >100% clamps full. + usedFraction: info.cpuCount > 0 ? load / info.cpuCount : undefined, + title: cpuTitle, + }; + const memoryUsed = usedFraction(info.memoryTotalBytes, info.memoryFreeBytes); + const memory: SystemStat = { + label: "Memory", + value: memoryUsed == null ? "—" : formatUsedPercent(memoryUsed), + unit: memoryUsed == null ? undefined : "used", + detail: `${formatBytes(info.memoryFreeBytes)} free of ${formatBytes(info.memoryTotalBytes)}`, + usedFraction: memoryUsed, + }; + const stats = [cpu, memory]; + const diskUsed = usedFraction(info.diskTotalBytes, info.diskAvailableBytes); + // Disk info is optional in the protocol; skip the tile instead of showing an empty gauge. + if (diskUsed != null) { + stats.push({ + label: "Disk", + value: formatUsedPercent(diskUsed), + unit: "used", + detail: `${formatBytes(info.diskAvailableBytes)} free of ${formatBytes(info.diskTotalBytes)}`, + usedFraction: diskUsed, + title: info.diskPath, + }); + } + return stats; +} + +const SYSTEM_STATS_PLACEHOLDER: SystemStat[] = [ + { label: "CPU", value: "—" }, + { label: "Memory", value: "—" }, + { label: "Disk", value: "—" }, +]; + function renderSystemCard(props: QuickSettingsProps) { if (props.systemInfoUnavailable) { return nothing; @@ -605,34 +710,34 @@ function renderSystemCard(props: QuickSettingsProps) { 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; + const stats = info ? buildSystemStats(info) : SYSTEM_STATS_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} + ${renderCardHeader( + icons.monitor, + "Gateway Host", + info + ? html`Up ${formatDurationHuman(info.uptimeMs)}` + : undefined, + )} +
+
+
+ ${info?.machineName ?? placeholder} +
+
+ ${info ? `${info.osLabel} · ${info.arch}` : placeholder} +
+
+ ${info ? `Node ${info.nodeVersion} · PID ${info.pid}` : placeholder} +
+ ${address ? html`${address}` : nothing} +
+
${stats.map(renderSystemStat)}
`; diff --git a/ui/src/styles/config-quick.css b/ui/src/styles/config-quick.css index 768b467d8e7..6cc62e435d7 100644 --- a/ui/src/styles/config-quick.css +++ b/ui/src/styles/config-quick.css @@ -711,6 +711,126 @@ openclaw-logs-page { color: var(--muted); } +/* ── Gateway Host card ── */ + +.qs-system { + display: grid; + grid-template-columns: minmax(200px, 280px) minmax(0, 1fr); + gap: 14px 24px; + align-items: start; + padding: 14px 16px 16px; +} + +.qs-system__identity { + display: grid; + align-content: start; + gap: 3px; + min-width: 0; +} + +.qs-system__name { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.2; + color: var(--text-strong); + overflow-wrap: anywhere; +} + +.qs-system__meta { + font-size: 0.75rem; + line-height: 1.4; + color: var(--muted); +} + +.qs-system__address { + width: fit-content; + max-width: 100%; + margin-top: 7px; + padding: 3px 9px; + border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--bg-elevated) 84%, transparent); + color: var(--text); + font-family: var(--mono); + font-size: 0.7rem; + overflow-wrap: anywhere; +} + +.qs-system__stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); + gap: 10px; +} + +.qs-stat { + display: grid; + align-content: start; + gap: 6px; + min-width: 0; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-elevated) 42%, var(--card) 58%); +} + +.qs-stat__label { + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.qs-stat__value { + display: flex; + align-items: baseline; + gap: 5px; + font-size: 1.25rem; + font-weight: 650; + letter-spacing: -0.02em; + line-height: 1.1; + color: var(--text-strong); + font-variant-numeric: tabular-nums; +} + +.qs-stat__unit { + font-size: 0.7rem; + font-weight: 550; + letter-spacing: 0; + color: var(--muted); +} + +.qs-stat__detail { + font-size: 0.7rem; + line-height: 1.35; + color: var(--muted); + overflow-wrap: anywhere; +} + +.qs-meter { + height: 4px; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--border) 55%, transparent); + overflow: hidden; +} + +.qs-meter__fill { + width: var(--qs-meter-fill, 0%); + height: 100%; + border-radius: inherit; + background: var(--ok); + transition: width var(--duration-normal) var(--ease-out); +} + +.qs-meter__fill--warn { + background: var(--warn); +} + +.qs-meter__fill--critical { + background: var(--danger, #ff6b78); +} + /* ── Footer ── */ .qs-footer { @@ -1031,6 +1151,10 @@ openclaw-logs-page { grid-template-columns: 1fr; } + .qs-system { + grid-template-columns: 1fr; + } + .qs-profiles__footer, .qs-profiles__actions { flex-direction: column;