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 = { 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; 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 (
采样中…
); return (
(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) => ( ))}
); } 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 (
{title} {SCOPE_LABEL[scope]}

{value}

{hint}

{children}
); } /** 最近 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 (
{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))}`} } > `${v.toFixed(1)}%`} max={100} label="CPU 占用趋势" /> {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)}` : ""} } > `${v.toFixed(1)}%`} max={100} label="内存占用趋势" /> ↓ {rate(d.network.rxBps)} } hint={`↑ ${rate(d.network.txBps)}${d.network.rxTotalBytes != null ? ` · 累计收 ${formatBytes(d.network.rxTotalBytes, 1)}` : ""}`} > `${formatBytes(v, 1)}/s`} label="网络吞吐趋势(实线接收,虚线发送)" /> 读 {rate(d.diskIo.readBps)} } hint={`写 ${rate(d.diskIo.writeBps)}${d.hostUptimeSec != null ? ` · 主机已运行 ${formatDuration(d.hostUptimeSec)}` : ""}`} > `${formatBytes(v, 1)}/s`} label="磁盘读写趋势(实线读,虚线写)" />
); } const WINDOWS = [15, 60, 360]; /** 运行状态页「资源」节:窗口切换 + 四块磁贴 */ export function ResourceSection() { const [minutes, setMinutes] = useState(60); const q = useSystemResources(minutes); return (

资源

每 {q.data?.intervalSec ?? 5} 秒采样一次,只保留最近 6 小时(重启后重新累计) setMinutes(Number(v))} className="ml-auto"> {WINDOWS.map((n) => ( {n < 60 ? `${n} 分钟` : `${n / 60} 小时`} ))}
{q.isPending ? ( ) : q.isError ? ( q.refetch()} /> ) : ( )}
); }