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:
rootandClaude Opus 5.5 committed 2026-10-06 18:09:56 +08:00
1 parent 805ba33ddf
commit a1440401c4
6 files changed
+581 -83

No files matched your search

@@ -0,0 +1,137 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Dashboard, SystemHealth, SystemResources } from "@umt/contracts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { qk } from "~/lib/query-keys.ts";
import { HomeRuntime } from "./home-runtime.tsx";
// 首页运行状态简版卡:整体健康、CPU、内存、磁盘,以及下载器 / 站点 / 媒体库目录摘要,点进去是运行状态页。
const health = (over: Partial<SystemHealth> = {}): SystemHealth =>
({
level: "warn",
checkedAt: new Date().toISOString(),
api: { version: "t", startedAt: new Date().toISOString(), uptimeSec: 10 },
processes: [],
retiredProcesses: [],
sse: { connections: 0 },
db: { level: "ok", latencyMs: 1, sizeBytes: 1, connections: null, error: null },
disks: [
{
scope: "library_root",
id: "r1",
name: "电影",
path: "/media/movies",
level: "warn",
totalBytes: 1000,
usedBytes: 870,
availableBytes: 130,
usedRatio: 0.87,
error: null,
},
],
diskThresholds: { warnPercent: 85, criticalPercent: 90 },
mounts: [
{
kind: "library_root",
id: "r1",
name: "电影",
path: "/m",
status: "online",
reason: null,
sentinelChecked: true,
},
{
kind: "library_root",
id: "r2",
name: "剧集",
path: "/t",
status: "offline",
reason: "root_missing",
sentinelChecked: true,
},
],
engine: {
level: "ok",
pending: 0,
running: 0,
oldestPendingAgeSec: null,
finished24h: 0,
failed24h: 0,
failureRate24h: null,
},
schedules: { level: "ok", total: 0, enabled: 0, running: 0, failing: [], failedRuns24h: 0 },
storage: { level: "ok", alerts: [] },
...over,
}) as SystemHealth;
const resources: SystemResources = {
at: new Date().toISOString(),
intervalSec: 5,
bucketSec: 5,
cpu: { scope: "cgroup", percent: 42.4, cores: 4, hostPercent: 30, load: [1, 1, 1] },
memory: {
scope: "cgroup",
usedBytes: 900,
limitBytes: 1000,
percent: 92,
hostTotalBytes: 2000,
hostAvailableBytes: 800,
swapUsedBytes: 0,
},
network: { scope: "cgroup", rxBps: 1, txBps: 1, rxTotalBytes: 1, txTotalBytes: 1 },
diskIo: { scope: "cgroup", readBps: 1, writeBps: 1 },
hostUptimeSec: 100,
samples: [],
};
function render(h: SystemHealth, downloaders: Dashboard["sources"]["downloaders"]) {
const client = new QueryClient();
client.setQueryData(qk.sub("system", "health"), h);
client.setQueryData(qk.sub("system", "resources", 30), resources);
client.setQueryData([...qk.dashboard, "sites-accounts"], {
items: [
{ id: "s1", enabled: true, accountStatus: "ok" },
{ id: "s2", enabled: true, accountStatus: "expired" },
{ id: "s3", enabled: false, accountStatus: "expired" },
],
nextCursor: null,
});
return renderToStaticMarkup(
createElement(
QueryClientProvider,
{ client },
createElement(MemoryRouter, null, createElement(HomeRuntime, { downloaders })),
),
);
}
describe("首页运行状态卡", () => {
it("整体健康、CPU / 内存、磁盘、下载器 / 站点 / 媒体库目录摘要,链到运行状态页", () => {
const html = render(health(), [
{ id: "d1", name: "qb", enabled: true, health: "healthy", lastSeenAt: null },
{ id: "d2", name: "qb2", enabled: true, health: "offline", lastSeenAt: null },
] as Dashboard["sources"]["downloaders"]);
expect(html).toContain("运行状态");
expect(html).toContain('href="/console/status"');
expect(html).toContain("需要关注");
expect(html).toContain("42%");
// 内存 ≥ 90% 标红
expect(html).toMatch(/text-destructive[^"]*">92%/);
expect(html).toContain("电影");
expect(html).toContain("87%");
// 摘要:正常数 / 总数(停用的不算)
expect(html).toMatch(/下载器<span[^>]*>1\/2/);
expect(html).toMatch(/站点<span[^>]*>1\/2/);
expect(html).toMatch(/媒体库目录<span[^>]*>1\/2/);
});
it("全部正常时写「全部正常」,没有配置的项不列", () => {
const html = render(health({ level: "ok", mounts: [] }), []);
expect(html).toContain("全部正常");
expect(html).not.toContain("媒体库目录<");
expect(html).not.toContain("下载器<span");
});
});
@@ -1,13 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { type Dashboard, DownloadBoard, type HealthLevel } from "@umt/contracts";
import { ArrowRight, Download, HardDrive, type LucideIcon } from "lucide-react";
import { ArrowRight, Download, Gauge, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { useSitesWithAccounts } from "~/api/dashboard.ts";
import { apiParsed } from "~/api/parse.ts";
import { useSystemHealth } from "~/api/system.ts";
import { useSystemHealth, useSystemResources } from "~/api/system.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Sparkline, usageTone } from "~/components/status/resource-section.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
@@ -48,74 +50,129 @@ function OverviewCard({
);
}
function StorageOverview() {
const q = useSystemHealth(60_000);
const mediaDisks = q.data?.disks.filter((d) => d.scope !== "data_dir") ?? [];
const disks = mediaDisks.length ? mediaDisks : (q.data?.disks ?? []);
const BAD_ACCOUNT = ["invalid", "expired", "challenge"];
/** 一行「名称 正常数 / 总数」;有异常时数值标黄 */
function SummaryItem({ label, ok, total, to }: { label: string; ok: number; total: number; to: string }) {
if (!total) return null;
return (
<OverviewCard title="存储情况" icon={HardDrive} to={consolePath("status")}>
<Link to={to} className="inline-flex items-center gap-1 hover:text-foreground">
<span
aria-hidden
className={cn("inline-block size-1.5 rounded-full", ok < total ? "bg-warning" : "bg-success")}
/>
{label}
<span className={cn("tabular-nums", ok < total ? "text-warning" : "text-foreground")}>
{ok}/{total}
</span>
</Link>
);
}
/**
* 运行状态简版(界面第二轮,参考 MP 仪表盘):整体健康、CPU、内存、磁盘,以及下载器 / 站点 / 媒体库目录在线摘要。
* 详细版在系统 → 运行状态。
*/
function RuntimeOverview({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
const q = useSystemHealth(60_000);
const r = useSystemResources(30, 10_000);
const sites = useSitesWithAccounts();
const h = q.data;
const res = r.data;
const mediaDisks = h?.disks.filter((d) => d.scope !== "data_dir") ?? [];
const disks = (mediaDisks.length ? mediaDisks : (h?.disks ?? []))
.filter((d) => d.usedRatio !== null)
.sort((a, b) => (b.usedRatio ?? 0) - (a.usedRatio ?? 0))
.slice(0, 3);
const enabledDl = (downloaders ?? []).filter((d) => d.enabled);
const okDl = enabledDl.filter((d) => !["offline", "failing"].includes(d.health)).length;
const siteRows = (sites.data?.items ?? []).filter((s) => s.enabled);
const okSites = siteRows.filter((s) => !(s.accountStatus && BAD_ACCOUNT.includes(s.accountStatus))).length;
const roots = h?.mounts.filter((m) => m.kind === "library_root") ?? [];
const okRoots = roots.filter((m) => m.status === "online").length;
const level = h ? LEVEL[h.level === "unknown" ? "unknown" : h.level] : null;
return (
<OverviewCard title="运行状态" icon={Gauge} to={consolePath("status")}>
{q.isPending ? (
<LoadingState rows={2} />
<LoadingState rows={3} />
) : q.isError ? (
<ErrorState plain title="存储状态加载失败" onRetry={() => q.refetch()} className="py-6" />
) : !disks.length ? (
<EmptyState plain icon={HardDrive} title="暂无存储状态" className="py-6" />
) : (
<ul className="space-y-4">
{disks.map((d) => {
const pct = d.usedRatio === null ? null : d.usedRatio * 100;
const state = LEVEL[d.level];
return (
<li key={`${d.scope}-${d.id ?? d.path}`} className="min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium" title={d.name}>
{d.name !== SCOPE[d.scope] ? (
<span className="mr-1.5 text-meta font-normal text-muted-foreground">{SCOPE[d.scope]}</span>
) : null}
{d.name}
</p>
<StatusBadge tone={state.tone}>
{state.label}
{pct === null ? "" : ` · ${Math.round(pct)}%`}
</StatusBadge>
</div>
{pct !== null ? (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
d.level === "critical"
? "bg-destructive"
: d.level === "warn"
? "bg-warning"
: "bg-foreground/60",
)}
style={{ width: `${pct}%` }}
/>
</div>
) : null}
<p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
<span>
剩余{" "}
<span className="font-medium text-foreground">
{d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)}
</span>
<ErrorState plain title="运行状态加载失败" onRetry={() => q.refetch()} className="py-6" />
) : h ? (
<>
<div className="mb-3 flex items-center gap-2">
{level ? <StatusBadge tone={level.tone}>{h.level === "ok" ? "全部正常" : level.label}</StatusBadge> : null}
<span className="text-meta text-muted-foreground">检查于 {formatRelative(h.checkedAt)}</span>
</div>
<div className="grid grid-cols-2 gap-3">
{[
{ label: "CPU", v: res?.cpu.percent, key: "cpuPercent" as const },
{ label: "内存", v: res?.memory.percent, key: "memoryPercent" as const },
].map((x) => (
<div key={x.label} className="min-w-0 rounded-lg border border-border/70 px-3 py-2">
<p className="flex items-center justify-between text-meta text-muted-foreground">
{x.label}
<span className={cn("text-body font-semibold tabular-nums", usageTone(x.v))}>
{x.v == null ? "—" : `${Math.round(x.v)}%`}
</span>
<span>已用 {d.usedBytes === null ? "未知" : formatBytes(d.usedBytes, 1)}</span>
<span>总量 {d.totalBytes === null ? "未知" : formatBytes(d.totalBytes, 1)}</span>
</p>
<p className="mt-1 truncate text-meta text-muted-foreground" title={d.path}>
{d.path}
</p>
{d.error ? <p className="mt-1 text-meta text-warning">无法读取磁盘用量</p> : null}
</li>
);
})}
</ul>
{res ? (
<Sparkline
samples={res.samples}
keys={[{ key: x.key, name: x.label }]}
format={(v) => `${v.toFixed(1)}%`}
max={100}
height={32}
label={`${x.label}占用趋势(最近 30 分钟)`}
/>
) : (
<div className="h-8" />
)}
</div>
))}
</div>
{disks.length ? (
<ul className="mt-3 space-y-2">
{disks.map((d) => {
const pct = (d.usedRatio ?? 0) * 100;
return (
<li key={`${d.scope}-${d.id ?? d.path}`} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-meta">
<span className="min-w-0 truncate" title={d.path}>
<span className="mr-1 text-muted-foreground">{SCOPE[d.scope]}</span>
{d.name}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{Math.round(pct)}% · 剩余{" "}
{d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)}
</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
d.level === "critical"
? "bg-destructive"
: d.level === "warn"
? "bg-warning"
: "bg-foreground/60",
)}
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
) : null}
<p className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
<SummaryItem label="下载器" ok={okDl} total={enabledDl.length} to={consolePath("downloads")} />
<SummaryItem label="站点" ok={okSites} total={siteRows.length} to={consolePath("sites")} />
<SummaryItem label="媒体库目录" ok={okRoots} total={roots.length} to={consolePath("status")} />
</p>
</>
) : (
<EmptyState plain icon={Gauge} title="暂无运行状态" className="py-6" />
)}
{q.data && !q.isError ? (
<p className="mt-3 text-meta text-muted-foreground">所在磁盘用量 · 检查于 {formatRelative(q.data.checkedAt)}</p>
) : null}
</OverviewCard>
);
}
@@ -202,7 +259,7 @@ function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources"
export function HomeRuntime({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
return (
<div className="mb-4 grid gap-4 lg:grid-cols-2">
<StorageOverview />
<RuntimeOverview downloaders={downloaders} />
<DownloaderOverview downloaders={downloaders} />
</div>
);