feat(ui): redesign Gateway Host settings card as identity block with resource meters (#102714)

This commit is contained in:
Peter Steinberger 2026-07-09 12:22:30 +01:00 committed by GitHub
parent 6c3a46fb41
commit 2806195e11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 350 additions and 39 deletions

View file

@ -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<HTMLElement>(".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> = {}): 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", () => {

View file

@ -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`
<div class="qs-row">
<span class="qs-row__label">${label}</span>
<span class="qs-row__value" title=${title ?? ""}>${value}</span>
<div
class="qs-meter"
role="meter"
aria-label="${label} usage"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow=${percent}
>
<div
class="qs-meter__fill qs-meter__fill--${systemMeterTone(clamped)}"
style="--qs-meter-fill: ${percent}%"
></div>
</div>
`;
}
function renderSystemStat(stat: SystemStat) {
return html`
<div class="qs-stat" title=${stat.title ?? ""}>
<div class="qs-stat__label">${stat.label}</div>
<div class="qs-stat__value">
${stat.value}${stat.unit ? html` <span class="qs-stat__unit">${stat.unit}</span>` : nothing}
</div>
${stat.usedFraction == null ? nothing : renderSystemMeter(stat.label, stat.usedFraction)}
${stat.detail ? html`<div class="qs-stat__detail">${stat.detail}</div>` : nothing}
</div>
`;
}
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`
<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}
${renderCardHeader(
icons.monitor,
"Gateway Host",
info
? html`<span class="qs-badge qs-badge--ok"
>Up ${formatDurationHuman(info.uptimeMs)}</span
>`
: undefined,
)}
<div class="qs-card__body qs-system">
<div class="qs-system__identity">
<div class="qs-system__name" title=${hostTitle ?? ""}>
${info?.machineName ?? placeholder}
</div>
<div class="qs-system__meta">
${info ? `${info.osLabel} · ${info.arch}` : placeholder}
</div>
<div class="qs-system__meta">
${info ? `Node ${info.nodeVersion} · PID ${info.pid}` : placeholder}
</div>
${address ? html`<code class="qs-system__address">${address}</code>` : nothing}
</div>
<div class="qs-system__stats">${stats.map(renderSystemStat)}</div>
</div>
</div>
`;

View file

@ -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;