Files
UHD_Media_Tool/apps/web/app/components/status/resource-section.tsx
T
rootandClaude Opus 5.5 89795802b5 docs(copy): 日志、计划、运行状态文案按 human-copy 重写
- 「立即执行」统一为「立即运行」,Toast 写「已加入队列」
- 巡检不再提「待处理」:触发条件写「有需要处理的事项时」,开关叫「生成巡检报告」
- 下载巡检触发条件写清包含失败的下载

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:46:30 +08:00

272 lines
9.2 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)]}
contentStyle={{
background: "var(--color-popover)",
border: "1px solid var(--color-border)",
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>
);
}