mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(ui): redesign Gateway Host settings card as identity block with resource meters (#102714)
This commit is contained in:
parent
6c3a46fb41
commit
2806195e11
3 changed files with 350 additions and 39 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue