- 「立即执行」统一为「立即运行」,Toast 写「已加入队列」 - 巡检不再提「待处理」:触发条件写「有需要处理的事项时」,开关叫「生成巡检报告」 - 下载巡检触发条件写清包含失败的下载 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
272 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|