Files
UHD_Media_Tool/apps/web/app/components/status/resource-section.tsx
T
rootandClaude Opus 5.5 09a2adb637 feat(web): 设计规范与 uhd_web 同步(截至 2026-10-08)
- 材质统一边光:slab / float / pill / pill-selected / seg-thumb / chrome-side 改为左上亮右下暗的 1px 边光 + 外侧暗线;新增 lens / field / chip 参数与 pg-float-modal
- 主按钮:转动光谱环改为静态左上光谱高光,悬停沿边扫过一小段;次按钮同为 lens 玻璃
- Input / Textarea / SelectTrigger 挂 .field-glass,原生下拉与实体选择器同步;对话框按钮行 gap-3
- 提示条改 pg-float(状态色只在图标、展开不叠放);新增 GlassTooltip 接管 title 提示
- 触屏判断 input-modality.ts、touch / hover 自定义变体;animate-pg-pop 改 backwards;scroll-fade-end
- 控制台外壳:侧栏无硬分隔线、折叠开关进标志行;顶栏去磨砂,右侧控件收进玻璃胶囊
- 登录页居中单卡 auth-card;详情头图「订阅」改回普通主按钮,删除 pill-solid;发现页海报角标改 glass-chip-media
- 引入 simple-liquid-glass(liquid-surface.tsx);更新 docs/design 三份规范与 CHANGELOG

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 02:29:19 +08:00

274 lines
9.3 KiB
TypeScript

import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts";
import { Activity, Cpu, HardDrive, MemoryStick, Network } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Line, LineChart, ResponsiveContainer, Tooltip, YAxis } from "recharts";
import { useSystemResources } from "~/api/system.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { formatDuration } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
// 资源监控(界面第二轮,参考 MP 仪表盘的 CPU / 内存 / 网络组件):四块磁贴,每块一个当前值 + 小趋势线。
// 图表沿用运行状态页的无彩口径:前景色细线,一张图一个量;网络收 / 发两条线靠线型与图例区分。
const SCOPE_LABEL: Record<ResourceScope, string> = { cgroup: "本容器", host: "整机", none: "取不到" };
const pct = (v: number | null | undefined) => (v == null ? "—" : `${Math.round(v)}%`);
const rate = (v: number | null | undefined) => (v == null ? "—" : `${formatBytes(v, 1)}/s`);
/** 用量分档:≥ 90% 红、≥ 75% 黄(只给数值上色,文字仍写清) */
export function usageTone(v: number | null | undefined): string {
if (v == null) return "text-foreground";
return v >= 90 ? "text-destructive" : v >= 75 ? "text-warning" : "text-foreground";
}
type Key = Exclude<keyof ResourceSample, "at">;
function clock(iso: string) {
return new Date(iso).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
/** 小趋势线:无坐标轴,悬停看时间与数值 */
export function Sparkline({
samples,
keys,
format,
max,
height = 48,
label,
}: {
samples: ResourceSample[];
keys: { key: Key; name: string; dash?: string }[];
format: (v: number) => string;
/** 纵轴上限(百分比类给 100,保持刻度稳定) */
max?: number;
height?: number;
label: string;
}) {
const has = samples.some((s) => keys.some((k) => s[k.key] != null));
if (!has)
return (
<div className="flex items-center text-meta text-muted-foreground" style={{ height }}>
采样中…
</div>
);
return (
<div role="img" aria-label={label} className="text-muted-foreground" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={samples} margin={{ top: 4, right: 2, bottom: 2, left: 2 }}>
<YAxis hide domain={[0, max ?? "auto"]} />
<Tooltip
labelFormatter={(_, p) => (p?.[0]?.payload?.at ? clock(String(p[0].payload.at)) : "")}
formatter={(v, name) => [format(Number(v)), String(name)]}
// 悬浮层材质与 pg-float 同一组参数
contentStyle={{
background: "var(--mat-float-bg)",
border: "1px solid var(--mat-float-border)",
boxShadow: "var(--mat-float-shadow)",
borderRadius: 12,
fontSize: 12,
color: "var(--color-foreground)",
}}
cursor={{ stroke: "var(--color-muted-foreground)", strokeDasharray: "3 3" }}
/>
{keys.map((k, i) => (
<Line
key={k.key}
type="monotone"
dataKey={k.key}
name={k.name}
stroke="var(--color-foreground)"
strokeOpacity={i ? 0.5 : 0.85}
strokeDasharray={k.dash}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--color-background)" }}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
function Tile({
icon: Icon,
title,
scope,
value,
valueClass,
hint,
children,
}: {
icon: typeof Cpu;
title: string;
scope: ResourceScope;
value: ReactNode;
valueClass?: string;
hint: ReactNode;
children: ReactNode;
}) {
return (
<section className="pg-slab min-w-0 rounded-xl p-4">
<div className="flex items-center gap-1.5 text-meta text-muted-foreground">
<Icon className="h-3.5 w-3.5" aria-hidden />
<span>{title}</span>
<span className="ml-auto">{SCOPE_LABEL[scope]}</span>
</div>
<p className={cn("mt-1 text-title-sm font-semibold tabular-nums", valueClass)}>{value}</p>
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">{hint}</p>
<div className="mt-2">{children}</div>
</section>
);
}
/** 最近 n 分钟平均(MP 的 1m / 5m / 15m 平均) */
function avg(samples: ResourceSample[], key: Key, minutes: number): number | null {
const from = Date.now() - minutes * 60_000;
const v = samples
.filter((s) => Date.parse(s.at) >= from)
.map((s) => s[key])
.filter((x): x is number => x != null);
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
}
export function ResourceTiles({ d }: { d: SystemResources }) {
const m = d.memory;
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Tile
icon={Cpu}
title="CPU"
scope={d.cpu.scope}
value={pct(d.cpu.percent)}
valueClass={usageTone(d.cpu.percent)}
hint={
<>
{d.cpu.cores ? `${d.cpu.cores} 核` : ""}
{d.cpu.load ? ` · 负载 ${d.cpu.load.map((x) => x.toFixed(2)).join(" / ")}` : ""}
{` · 5 分钟平均 ${pct(avg(d.samples, "cpuPercent", 5))}`}
</>
}
>
<Sparkline
samples={d.samples}
keys={[{ key: "cpuPercent", name: "CPU" }]}
format={(v) => `${v.toFixed(1)}%`}
max={100}
label="CPU 占用趋势"
/>
</Tile>
<Tile
icon={MemoryStick}
title="内存"
scope={m.scope}
value={pct(m.percent)}
valueClass={usageTone(m.percent)}
hint={
<>
{m.usedBytes != null ? formatBytes(m.usedBytes, 1) : "—"}
{m.limitBytes != null ? ` / ${formatBytes(m.limitBytes, 1)}` : ""}
{m.scope === "cgroup" && m.hostTotalBytes != null && m.hostAvailableBytes != null
? ` · 整机可用 ${formatBytes(m.hostAvailableBytes, 1)}`
: ""}
</>
}
>
<Sparkline
samples={d.samples}
keys={[{ key: "memoryPercent", name: "内存" }]}
format={(v) => `${v.toFixed(1)}%`}
max={100}
label="内存占用趋势"
/>
</Tile>
<Tile
icon={Network}
title="网络"
scope={d.network.scope}
value={
<>
<span className="text-caption font-normal text-muted-foreground">↓ </span>
{rate(d.network.rxBps)}
</>
}
hint={`↑ ${rate(d.network.txBps)}${d.network.rxTotalBytes != null ? ` · 累计收 ${formatBytes(d.network.rxTotalBytes, 1)}` : ""}`}
>
<Sparkline
samples={d.samples}
keys={[
{ key: "netRxBps", name: "接收" },
{ key: "netTxBps", name: "发送", dash: "4 3" },
]}
format={(v) => `${formatBytes(v, 1)}/s`}
label="网络吞吐趋势(实线接收,虚线发送)"
/>
</Tile>
<Tile
icon={HardDrive}
title="磁盘读写"
scope={d.diskIo.scope}
value={
<>
<span className="text-caption font-normal text-muted-foreground">读 </span>
{rate(d.diskIo.readBps)}
</>
}
hint={`写 ${rate(d.diskIo.writeBps)}${d.hostUptimeSec != null ? ` · 主机已运行 ${formatDuration(d.hostUptimeSec)}` : ""}`}
>
<Sparkline
samples={d.samples}
keys={[
{ key: "diskReadBps", name: "读" },
{ key: "diskWriteBps", name: "写", dash: "4 3" },
]}
format={(v) => `${formatBytes(v, 1)}/s`}
label="磁盘读写趋势(实线读,虚线写)"
/>
</Tile>
</div>
);
}
const WINDOWS = [15, 60, 360];
/** 运行状态页「资源」节:窗口切换 + 四块磁贴 */
export function ResourceSection() {
const [minutes, setMinutes] = useState(60);
const q = useSystemResources(minutes);
return (
<section className="min-w-0">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" aria-hidden />
<h2 className="text-body font-semibold text-foreground">资源</h2>
<span className="text-meta text-muted-foreground">
每 {q.data?.intervalSec ?? 5} 秒采样一次,只保留最近 6 小时(重启后重新累计)
</span>
<Tabs value={String(minutes)} onValueChange={(v) => setMinutes(Number(v))} className="ml-auto">
<TabsList className="pb-0">
{WINDOWS.map((n) => (
<TabsTrigger key={n} value={String(n)} className="h-7 px-2.5 text-caption">
{n < 60 ? `${n} 分钟` : `${n / 60} 小时`}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
{q.isPending ? (
<LoadingState rows={3} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : (
<ResourceTiles d={q.data} />
)}
</section>
);
}