diff --git a/platform_web/components/OperationControls.test.tsx b/platform_web/components/OperationControls.test.tsx index 3c8700c..327a7ba 100644 --- a/platform_web/components/OperationControls.test.tsx +++ b/platform_web/components/OperationControls.test.tsx @@ -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( + <> + + + + + ); + + 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("--"); + }); }); diff --git a/platform_web/components/OperationControls.tsx b/platform_web/components/OperationControls.tsx index 6f01495..623bda5 100644 --- a/platform_web/components/OperationControls.tsx +++ b/platform_web/components/OperationControls.tsx @@ -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 ( -
- {label} - - {known && = 85 ? " usage-high" : ""}`} style={{ width: `${clamped}%` }} />} +
+ {label}{stateLabel} + {known ? `${rounded}%` : stateLabel} + + {known ? : {trackText}} - {known ? `${Math.round(clamped)}%` : "--"}
); } diff --git a/platform_web/pages/HomePage.tsx b/platform_web/pages/HomePage.tsx index bbee17d..ad827f9 100644 --- a/platform_web/pages/HomePage.tsx +++ b/platform_web/pages/HomePage.tsx @@ -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 (
@@ -209,9 +210,9 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) { ) : ( <>
- - - + + +
{moduleFreshnessLabel(metricAverages.refreshedAt)} {(usage.status === "error" || metrics.status === "error") && ( diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index fc64b7b..b68acf3 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -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) {
- - - + + +
diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index ecf0873..843246b 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -828,9 +828,9 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
- - - + + +
{metricsUnavailable && 指标不可用} {!metricsUnavailable && metricsWaiting && 等待指标上报} diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 02cc972..94b0e1a 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -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}