feat(web): 运行状态加资源监控(CPU / 内存 / 网络 / 磁盘读写与趋势、进程 CPU),首页换成运行状态简版卡
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
805ba33ddf
commit
a1440401c4
6 files changed
+581
-83
No files matched your search
@@ -0,0 +1,271 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user