Improve resource usage meter states and accessibility

This commit is contained in:
npc0-hue
2026-08-21 16:53:46 +08:00
parent 8aeb488179
commit 2381b69d24
6 changed files with 48 additions and 21 deletions
@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import operationControlsSource from "./OperationControls.tsx?raw";
import { ConfirmDialog, ManagementDialog } from "./OperationControls";
import { ConfirmDialog, ManagementDialog, UsageMeter } from "./OperationControls";
describe("shared operation dialogs", () => {
it("renders labelled, busy-aware confirmation semantics", () => {
@@ -42,4 +42,27 @@ describe("shared operation dialogs", () => {
expect(html).toContain("编辑资源");
expect(html).toContain('aria-modal="true"');
});
it("renders resource meters with meaningful known, pending, and missing states", () => {
const html = renderToStaticMarkup(
<>
<UsageMeter label="磁盘" percent={78} />
<UsageMeter label="CPU" pending />
<UsageMeter label="内存" />
</>
);
expect(html).toContain("磁盘");
expect(html).toContain("78%");
expect(html).toContain("偏高");
expect(html).toContain('role="meter"');
expect(html).toContain('aria-valuenow="78"');
expect(html).toContain("CPU");
expect(html).toContain("读取中");
expect(html).toContain("等待指标");
expect(html).toContain("内存");
expect(html).toContain("未上报");
expect(html).toContain("等待 Run");
expect(html).not.toContain("--");
});
});
+11 -6
View File
@@ -140,18 +140,23 @@ function useDialogLifecycle(open: boolean, onClose: () => void, canClose: boolea
interface UsageMeterProps {
label: string;
percent?: number;
pending?: boolean;
}
export function UsageMeter({ label, percent }: UsageMeterProps) {
export function UsageMeter({ label, percent, pending = false }: UsageMeterProps) {
const known = typeof percent === "number" && Number.isFinite(percent);
const clamped = known ? Math.max(0, Math.min(100, percent)) : 0;
const rounded = Math.round(clamped);
const state = known ? (clamped >= 85 ? "critical" : clamped >= 70 ? "watch" : "normal") : pending ? "pending" : "missing";
const stateLabel = known ? (clamped >= 85 ? "警戒" : clamped >= 70 ? "偏高" : "正常") : pending ? "读取中" : "未上报";
const trackText = pending ? "等待指标" : "等待 Run";
return (
<div className="usage-meter">
<span>{label}</span>
<span className="usage-meter-track" role="img" aria-label={known ? `${label} ${Math.round(clamped)}%` : `${label} 暂无数据`}>
{known && <span className={`usage-meter-fill${clamped >= 85 ? " usage-high" : ""}`} style={{ width: `${clamped}%` }} />}
<div className={`usage-meter usage-meter-${state}`} title={known ? `${label} ${rounded}% ${stateLabel}` : `${label} ${stateLabel}`}>
<span className="usage-meter-label"><span>{label}</span><small>{stateLabel}</small></span>
<span className="usage-meter-value">{known ? `${rounded}%` : stateLabel}</span>
<span className="usage-meter-track" role={known ? "meter" : "img"} aria-label={known ? `${label} ${rounded}% ${stateLabel}` : `${label} ${stateLabel}`} aria-valuemin={known ? 0 : undefined} aria-valuemax={known ? 100 : undefined} aria-valuenow={known ? rounded : undefined} aria-valuetext={known ? undefined : stateLabel}>
{known ? <span className="usage-meter-fill" style={{ width: `${clamped}%` }} /> : <span className="usage-meter-placeholder">{trackText}</span>}
</span>
<span className="usage-meter-value">{known ? `${Math.round(clamped)}%` : "--"}</span>
</div>
);
}
+4 -3
View File
@@ -145,6 +145,7 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
}
return { cpu: undefined, memory: undefined, disk: undefined, source: metrics.status === "error" || usage.status === "error" ? "指标不可用" : "等待指标", refreshedAt: undefined };
}, [usage, metrics]);
const resourceMetricsPending = usage.status === "loading" || metrics.status === "loading";
return (
<div className="console-page operations-overview-page">
@@ -209,9 +210,9 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
) : (
<>
<div className="server-card-meters">
<UsageMeter label="CPU" percent={metricAverages.cpu} />
<UsageMeter label="内存" percent={metricAverages.memory} />
<UsageMeter label="磁盘" percent={metricAverages.disk} />
<UsageMeter label="CPU" percent={metricAverages.cpu} pending={resourceMetricsPending} />
<UsageMeter label="内存" percent={metricAverages.memory} pending={resourceMetricsPending} />
<UsageMeter label="磁盘" percent={metricAverages.disk} pending={resourceMetricsPending} />
</div>
<span className="console-note">{moduleFreshnessLabel(metricAverages.refreshedAt)}</span>
{(usage.status === "error" || metrics.status === "error") && (
+4 -3
View File
@@ -119,6 +119,7 @@ export function ServerDetailPage(props: PageComponentProps) {
const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified";
const detailStateText = instance.status === "ready" && detailFreshness === "fresh" ? stateLabel(instance.data.state) : instance.status === "ready" ? `最后观测:${stateLabel(instance.data.state)}Run 未验证)` : "未验证";
const detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]);
const metricsWaiting = metrics?.source === "run-metrics-pending";
useEffect(() => {
if (!params.routeKey || !readyPlugin?.pages.some((page) => page.key === params.routeKey)) return;
@@ -249,9 +250,9 @@ export function ServerDetailPage(props: PageComponentProps) {
<HeaderStat label="指标" value={metricFreshnessLabel(metrics)} />
</div>
<div className="server-detail-meter-strip">
<UsageMeter label="CPU" percent={metrics?.cpuPercent} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
<UsageMeter label="CPU" percent={metrics?.cpuPercent} pending={metricsWaiting} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} pending={metricsWaiting} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} pending={metricsWaiting} />
</div>
</header>
+3 -3
View File
@@ -828,9 +828,9 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
</span>
</div>
<div className="server-card-meters">
<UsageMeter label="CPU" percent={metrics?.cpuPercent} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
<UsageMeter label="CPU" percent={metrics?.cpuPercent} pending={metricsPending || metricsWaiting} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} pending={metricsPending || metricsWaiting} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} pending={metricsPending || metricsWaiting} />
</div>
{metricsUnavailable && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
{!metricsUnavailable && metricsWaiting && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
+2 -5
View File
@@ -461,11 +461,8 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-card-stat span{color:color-mix(in srgb,var(--ink) 78%,var(--accent-deep));font-size:11px;font-weight:800;line-height:1.15;text-shadow:0 1px 2px rgba(0,0,0,.48)}
.server-card-stat strong{color:color-mix(in srgb,var(--ink) 94%,#fff);font-size:15px;font-weight:850;line-height:1.16;overflow-wrap:anywhere;text-shadow:0 1px 2px rgba(0,0,0,.56)}
.server-card-meters{display:grid;gap:6px}
.usage-meter{display:grid;grid-template-columns:52px minmax(0,1fr) 44px;gap:8px;align-items:center;font-size:12px;color:var(--ink-soft)}
.usage-meter-track{height:8px;border-radius:999px;background:rgba(255,255,255,.62);box-shadow:inset 0 0 0 1px var(--line);overflow:hidden}
.usage-meter-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,var(--teal),var(--accent),var(--pink));box-shadow:0 0 10px var(--candy-glow)}
.usage-meter-fill.usage-high{background:linear-gradient(90deg,var(--gold),var(--danger))}
.usage-meter-value{text-align:right;font-weight:700;color:var(--ink)}
.usage-meter{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"label value" "track track";gap:6px 10px;align-items:center;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line-strong) 58%,rgba(255,255,255,.14));border-radius:8px;background:linear-gradient(180deg,color-mix(in srgb,var(--surface-solid) 84%,rgba(255,255,255,.06)),color-mix(in srgb,var(--surface-solid) 62%,var(--accent-soft))),var(--glass-wash);box-shadow:inset 0 1px 0 color-mix(in srgb,var(--crystal-rim) 70%,transparent);font-size:12px;color:var(--ink-soft)}
.usage-meter-label{grid-area:label;display:flex;align-items:center;gap:7px;min-width:0;color:color-mix(in srgb,var(--ink) 82%,var(--accent-deep));font-weight:850}.usage-meter-label>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.usage-meter-label small{font-size:10px;font-weight:800;color:var(--ink-faint);white-space:nowrap}.usage-meter-value{grid-area:value;justify-self:end;min-width:50px;text-align:right;color:var(--ink);font-family:var(--font-mono);font-size:14px;font-weight:850;line-height:1}.usage-meter-track{grid-area:track;position:relative;display:block;height:10px;border-radius:999px;background:linear-gradient(90deg,color-mix(in srgb,var(--surface-solid) 72%,transparent),color-mix(in srgb,var(--accent-soft) 72%,transparent));box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line-strong) 74%,transparent);overflow:hidden}.usage-meter-fill{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--teal),var(--accent),var(--pink));box-shadow:0 0 12px var(--candy-glow)}.usage-meter-watch .usage-meter-fill{background:linear-gradient(90deg,var(--teal),var(--gold))}.usage-meter-critical .usage-meter-fill{background:linear-gradient(90deg,var(--gold),var(--danger))}.usage-meter-placeholder{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:color-mix(in srgb,var(--ink-faint) 84%,transparent);font-size:9px;font-weight:850;letter-spacing:.02em;text-transform:uppercase}.usage-meter-pending .usage-meter-track,.usage-meter-missing .usage-meter-track{background:repeating-linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 70%,transparent) 0 8px,color-mix(in srgb,var(--accent-soft) 62%,transparent) 8px 16px)}.usage-meter-pending .usage-meter-value,.usage-meter-missing .usage-meter-value{font-size:11px;color:var(--ink-soft)}.usage-meter-watch{border-color:color-mix(in srgb,var(--gold) 42%,var(--line-strong))}.usage-meter-critical{border-color:color-mix(in srgb,var(--danger) 48%,var(--line-strong))}
.server-detail-header{display:grid;gap:12px;padding:18px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);box-shadow:inset 0 1px 0 var(--crystal-rim),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 24px rgba(255,255,255,.18);position:relative;overflow:hidden}
.server-detail-title-row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.server-detail-title-row>div:first-child{min-width:0}