Skip to content

Commit 2806195

Browse files
authored
feat(ui): redesign Gateway Host settings card as identity block with resource meters (#102714)
1 parent 6c3a46f commit 2806195

3 files changed

Lines changed: 350 additions & 39 deletions

File tree

ui/src/pages/config/quick.test.ts

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ function expectFileInput(input: Element | null | undefined): HTMLInputElement {
3131
return input;
3232
}
3333

34+
function expectStatByLabel(container: Element, text: string): HTMLElement {
35+
const stat = Array.from(container.querySelectorAll<HTMLElement>(".qs-stat")).find(
36+
(candidate) => candidate.querySelector(".qs-stat__label")?.textContent?.trim() === text,
37+
);
38+
if (!(stat instanceof HTMLElement)) {
39+
throw new Error(`Expected system stat "${text}"`);
40+
}
41+
return stat;
42+
}
43+
3444
function createProps(overrides: Partial<QuickSettingsProps> = {}): QuickSettingsProps {
3545
return {
3646
currentModel: "gpt-5.5",
@@ -164,15 +174,82 @@ describe("renderQuickSettings", () => {
164174
container,
165175
);
166176

167-
const hostRow = expectRowByLabel(container, "Host");
168-
expect(hostRow.querySelector(".qs-row__value")?.textContent).toBe("Gateway Mac");
169-
expect(hostRow.querySelector(".qs-row__value")?.getAttribute("title")).toBe("gateway.local");
170-
expect(expectRowByLabel(container, "Address").textContent).toContain("192.168.1.20:18789");
171-
expect(expectRowByLabel(container, "OS").textContent).toContain("macOS 26.5.0 · arm64");
172-
expect(expectRowByLabel(container, "Uptime").textContent).toContain("1h");
173-
expect(expectRowByLabel(container, "CPU").textContent).toContain("10 cores · load 1.2");
174-
expect(expectRowByLabel(container, "Memory").textContent).toContain("16 GB free of 32 GB");
175-
expect(expectRowByLabel(container, "Disk").textContent).toContain("463 GB free of 926 GB");
177+
const name = container.querySelector(".qs-system__name");
178+
expect(name?.textContent?.trim()).toBe("Gateway Mac");
179+
expect(name?.getAttribute("title")).toBe("gateway.local");
180+
expect(container.querySelector(".qs-system__address")?.textContent?.trim()).toBe(
181+
"192.168.1.20:18789",
182+
);
183+
const metas = Array.from(container.querySelectorAll(".qs-system__meta")).map((node) =>
184+
node.textContent?.trim(),
185+
);
186+
expect(metas).toEqual(["macOS 26.5.0 · arm64", "Node v24.1.0 · PID 1234"]);
187+
expect(
188+
container.querySelector(".qs-card--system .qs-card__header .qs-badge")?.textContent?.trim(),
189+
).toBe("Up 1h");
190+
191+
const cpu = expectStatByLabel(container, "CPU");
192+
expect(cpu.querySelector(".qs-stat__value")?.textContent?.replace(/\s+/g, " ").trim()).toBe(
193+
"1.2 load",
194+
);
195+
expect(cpu.querySelector(".qs-stat__detail")?.textContent?.trim()).toBe("10 cores");
196+
expect(cpu.getAttribute("title")).toBe("Apple M4 · Load average: 1.2 · 1.1 · 0.9");
197+
expect(cpu.querySelector(".qs-meter")?.getAttribute("aria-valuenow")).toBe("12");
198+
199+
const memory = expectStatByLabel(container, "Memory");
200+
expect(memory.querySelector(".qs-stat__value")?.textContent?.replace(/\s+/g, " ").trim()).toBe(
201+
"50% used",
202+
);
203+
expect(memory.querySelector(".qs-stat__detail")?.textContent?.trim()).toBe(
204+
"16 GB free of 32 GB",
205+
);
206+
207+
const disk = expectStatByLabel(container, "Disk");
208+
expect(disk.querySelector(".qs-stat__value")?.textContent?.replace(/\s+/g, " ").trim()).toBe(
209+
"50% used",
210+
);
211+
expect(disk.querySelector(".qs-stat__detail")?.textContent?.trim()).toBe(
212+
"463 GB free of 926 GB",
213+
);
214+
expect(disk.getAttribute("title")).toBe("/Users/operator/.openclaw");
215+
for (const fill of container.querySelectorAll(".qs-meter__fill")) {
216+
expect([...fill.classList]).toContain("qs-meter__fill--ok");
217+
}
218+
});
219+
220+
it("escalates meter tones as resources run hot", () => {
221+
const container = document.createElement("div");
222+
223+
render(
224+
renderQuickSettings(
225+
createProps({
226+
systemInfo: {
227+
machineName: "Gateway Mac",
228+
hostname: "gateway.local",
229+
platform: "darwin",
230+
release: "25.5.0",
231+
arch: "arm64",
232+
osLabel: "macOS 26.5.0",
233+
nodeVersion: "v24.1.0",
234+
pid: 1234,
235+
uptimeMs: 60_000,
236+
cpuCount: 10,
237+
loadAverage: [9.8, 9.1, 8.4],
238+
memoryTotalBytes: 34_359_738_368,
239+
memoryFreeBytes: 2_147_483_648,
240+
diskTotalBytes: 994_662_584_320,
241+
diskAvailableBytes: 198_932_516_864,
242+
},
243+
}),
244+
),
245+
container,
246+
);
247+
248+
const tone = (label: string) =>
249+
expectStatByLabel(container, label).querySelector(".qs-meter__fill")?.classList[1];
250+
expect(tone("CPU")).toBe("qs-meter__fill--critical");
251+
expect(tone("Memory")).toBe("qs-meter__fill--critical");
252+
expect(tone("Disk")).toBe("qs-meter__fill--warn");
176253
});
177254

178255
it("hides Gateway host details when the RPC is unavailable", () => {
@@ -190,8 +267,13 @@ describe("renderQuickSettings", () => {
190267

191268
const systemCard = container.querySelector(".qs-card--system");
192269
expect(systemCard).not.toBeNull();
193-
expect(expectRowByLabel(systemCard ?? container, "Host").textContent).toContain("—");
194-
expect(expectRowByLabel(systemCard ?? container, "Disk").textContent).toContain("—");
270+
expect(systemCard?.querySelector(".qs-system__name")?.textContent).toContain("—");
271+
for (const label of ["CPU", "Memory", "Disk"]) {
272+
const stat = expectStatByLabel(systemCard ?? container, label);
273+
expect(stat.querySelector(".qs-stat__value")?.textContent).toContain("—");
274+
expect(stat.querySelector(".qs-meter")).toBeNull();
275+
}
276+
expect(systemCard?.querySelector(".qs-system__address")).toBeNull();
195277
});
196278

197279
it("shows the current bootstrap default when config omits the explicit limit", () => {

ui/src/pages/config/quick.ts

Lines changed: 133 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -587,15 +587,120 @@ function renderSecurityCard(props: QuickSettingsProps) {
587587
`;
588588
}
589589

590-
function renderSystemRow(label: string, value: string, title?: string) {
590+
type SystemStat = {
591+
label: string;
592+
value: string;
593+
unit?: string;
594+
detail?: string;
595+
/** Used share of the resource (0..1); renders the meter bar when present. */
596+
usedFraction?: number;
597+
title?: string;
598+
};
599+
600+
// Meter tones reuse the badge palette: calm until 75%, warn to 92%, critical beyond.
601+
function systemMeterTone(fraction: number): "ok" | "warn" | "critical" {
602+
if (fraction >= 0.92) {
603+
return "critical";
604+
}
605+
if (fraction >= 0.75) {
606+
return "warn";
607+
}
608+
return "ok";
609+
}
610+
611+
function renderSystemMeter(label: string, fraction: number) {
612+
const clamped = Math.min(Math.max(fraction, 0), 1);
613+
const percent = Math.round(clamped * 100);
614+
return html`
615+
<div
616+
class="qs-meter"
617+
role="meter"
618+
aria-label="${label} usage"
619+
aria-valuemin="0"
620+
aria-valuemax="100"
621+
aria-valuenow=${percent}
622+
>
623+
<div
624+
class="qs-meter__fill qs-meter__fill--${systemMeterTone(clamped)}"
625+
style="--qs-meter-fill: ${percent}%"
626+
></div>
627+
</div>
628+
`;
629+
}
630+
631+
function renderSystemStat(stat: SystemStat) {
591632
return html`
592-
<div class="qs-row">
593-
<span class="qs-row__label">${label}</span>
594-
<span class="qs-row__value" title=${title ?? ""}>${value}</span>
633+
<div class="qs-stat" title=${stat.title ?? ""}>
634+
<div class="qs-stat__label">${stat.label}</div>
635+
<div class="qs-stat__value">
636+
${stat.value}${stat.unit ? html` <span class="qs-stat__unit">${stat.unit}</span>` : nothing}
637+
</div>
638+
${stat.usedFraction == null ? nothing : renderSystemMeter(stat.label, stat.usedFraction)}
639+
${stat.detail ? html`<div class="qs-stat__detail">${stat.detail}</div>` : nothing}
595640
</div>
596641
`;
597642
}
598643

644+
function usedFraction(totalBytes: number | undefined, freeBytes: number | undefined) {
645+
if (totalBytes == null || freeBytes == null || totalBytes <= 0) {
646+
return undefined;
647+
}
648+
return (totalBytes - freeBytes) / totalBytes;
649+
}
650+
651+
function formatUsedPercent(fraction: number) {
652+
return `${Math.round(Math.min(Math.max(fraction, 0), 1) * 100)}%`;
653+
}
654+
655+
function buildSystemStats(info: SystemInfoResult): SystemStat[] {
656+
const load = info.loadAverage?.[0];
657+
const loadTitle = info.loadAverage
658+
? `Load average: ${info.loadAverage.map((value) => value.toFixed(1)).join(" · ")}`
659+
: undefined;
660+
const cpuTitle = [info.cpuModel, loadTitle].filter(Boolean).join(" · ") || undefined;
661+
const coresLabel = `${info.cpuCount} core${info.cpuCount === 1 ? "" : "s"}`;
662+
const cpu: SystemStat =
663+
load == null
664+
? { label: "CPU", value: coresLabel, detail: info.cpuModel, title: cpuTitle }
665+
: {
666+
label: "CPU",
667+
value: load.toFixed(1),
668+
unit: "load",
669+
detail: coresLabel,
670+
// 1-minute load over core count approximates saturation; >100% clamps full.
671+
usedFraction: info.cpuCount > 0 ? load / info.cpuCount : undefined,
672+
title: cpuTitle,
673+
};
674+
const memoryUsed = usedFraction(info.memoryTotalBytes, info.memoryFreeBytes);
675+
const memory: SystemStat = {
676+
label: "Memory",
677+
value: memoryUsed == null ? "—" : formatUsedPercent(memoryUsed),
678+
unit: memoryUsed == null ? undefined : "used",
679+
detail: `${formatBytes(info.memoryFreeBytes)} free of ${formatBytes(info.memoryTotalBytes)}`,
680+
usedFraction: memoryUsed,
681+
};
682+
const stats = [cpu, memory];
683+
const diskUsed = usedFraction(info.diskTotalBytes, info.diskAvailableBytes);
684+
// Disk info is optional in the protocol; skip the tile instead of showing an empty gauge.
685+
if (diskUsed != null) {
686+
stats.push({
687+
label: "Disk",
688+
value: formatUsedPercent(diskUsed),
689+
unit: "used",
690+
detail: `${formatBytes(info.diskAvailableBytes)} free of ${formatBytes(info.diskTotalBytes)}`,
691+
usedFraction: diskUsed,
692+
title: info.diskPath,
693+
});
694+
}
695+
return stats;
696+
}
697+
698+
const SYSTEM_STATS_PLACEHOLDER: SystemStat[] = [
699+
{ label: "CPU", value: "—" },
700+
{ label: "Memory", value: "—" },
701+
{ label: "Disk", value: "—" },
702+
];
703+
599704
function renderSystemCard(props: QuickSettingsProps) {
600705
if (props.systemInfoUnavailable) {
601706
return nothing;
@@ -605,34 +710,34 @@ function renderSystemCard(props: QuickSettingsProps) {
605710
const hostTitle = info && info.hostname !== info.machineName ? info.hostname : undefined;
606711
const address = info?.lanAddress
607712
? `${info.lanAddress}${info.port == null ? "" : `:${info.port}`}`
608-
: placeholder;
609-
const osLabel = info ? `${info.osLabel} · ${info.arch}` : placeholder;
610-
const runtime = info ? `Node ${info.nodeVersion} · PID ${info.pid}` : placeholder;
611-
const cpu = info
612-
? `${info.cpuCount} cores${info.loadAverage ? ` · load ${info.loadAverage[0].toFixed(1)}` : ""}`
613-
: placeholder;
614-
const loadTitle = info?.loadAverage
615-
? `Load average: ${info.loadAverage.map((value) => value.toFixed(1)).join(" · ")}`
616713
: undefined;
617-
const cpuTitle = [info?.cpuModel, loadTitle].filter(Boolean).join(" · ") || undefined;
618-
const memory = info
619-
? `${formatBytes(info.memoryFreeBytes)} free of ${formatBytes(info.memoryTotalBytes)}`
620-
: placeholder;
621-
const hasDisk = info?.diskAvailableBytes != null && info.diskTotalBytes != null;
622-
const disk = hasDisk
623-
? `${formatBytes(info.diskAvailableBytes)} free of ${formatBytes(info.diskTotalBytes)}`
624-
: placeholder;
714+
const stats = info ? buildSystemStats(info) : SYSTEM_STATS_PLACEHOLDER;
625715

626716
return html`
627717
<div class="qs-card qs-card--system">
628-
${renderCardHeader(icons.monitor, "Gateway Host")}
629-
<div class="qs-card__body">
630-
${renderSystemRow("Host", info?.machineName ?? placeholder, hostTitle)}
631-
${renderSystemRow("Address", address)} ${renderSystemRow("OS", osLabel)}
632-
${renderSystemRow("Runtime", runtime)}
633-
${renderSystemRow("Uptime", info ? formatDurationHuman(info.uptimeMs) : placeholder)}
634-
${renderSystemRow("CPU", cpu, cpuTitle)} ${renderSystemRow("Memory", memory)}
635-
${info == null || hasDisk ? renderSystemRow("Disk", disk, info?.diskPath) : nothing}
718+
${renderCardHeader(
719+
icons.monitor,
720+
"Gateway Host",
721+
info
722+
? html`<span class="qs-badge qs-badge--ok"
723+
>Up ${formatDurationHuman(info.uptimeMs)}</span
724+
>`
725+
: undefined,
726+
)}
727+
<div class="qs-card__body qs-system">
728+
<div class="qs-system__identity">
729+
<div class="qs-system__name" title=${hostTitle ?? ""}>
730+
${info?.machineName ?? placeholder}
731+
</div>
732+
<div class="qs-system__meta">
733+
${info ? `${info.osLabel} · ${info.arch}` : placeholder}
734+
</div>
735+
<div class="qs-system__meta">
736+
${info ? `Node ${info.nodeVersion} · PID ${info.pid}` : placeholder}
737+
</div>
738+
${address ? html`<code class="qs-system__address">${address}</code>` : nothing}
739+
</div>
740+
<div class="qs-system__stats">${stats.map(renderSystemStat)}</div>
636741
</div>
637742
</div>
638743
`;

0 commit comments

Comments
 (0)