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

+10
View File
@@ -4,6 +4,7 @@ import type {
StorageCleanupTarget, StorageCleanupTarget,
SystemHealth, SystemHealth,
SystemHealthHistory, SystemHealthHistory,
SystemResources,
SystemStorage, SystemStorage,
} from "@umt/contracts"; } from "@umt/contracts";
import { apiData } from "~/lib/api.ts"; import { apiData } from "~/lib/api.ts";
@@ -50,3 +51,12 @@ export function useStorageCleanup() {
onSuccess: () => void qc.invalidateQueries({ queryKey: qk.sub("system", "storage") }), onSuccess: () => void qc.invalidateQueries({ queryKey: qk.sub("system", "storage") }),
}); });
} }
/** 资源监控:CPU / 内存 / 网络 / 磁盘读写的当前值与最近 minutes 分钟趋势(服务端每 5 秒采样) */
export function useSystemResources(minutes = 60, refetchMs = 5_000) {
return useQuery({
queryKey: qk.sub("system", "resources", minutes),
queryFn: ({ signal }) => apiData<SystemResources>("/system/resources", { params: { minutes }, signal }),
refetchInterval: refetchMs,
});
}
@@ -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 { useQuery } from "@tanstack/react-query";
import { type Dashboard, DownloadBoard, type HealthLevel } from "@umt/contracts"; 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 type { ReactNode } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { useSitesWithAccounts } from "~/api/dashboard.ts";
import { apiParsed } from "~/api/parse.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 { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts"; import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; 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 { formatRelative } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts"; import { cn, formatBytes } from "~/lib/utils.ts";
@@ -48,74 +50,129 @@ function OverviewCard({
); );
} }
function StorageOverview() { const BAD_ACCOUNT = ["invalid", "expired", "challenge"];
const q = useSystemHealth(60_000);
const mediaDisks = q.data?.disks.filter((d) => d.scope !== "data_dir") ?? []; /** 一行「名称 正常数 / 总数」;有异常时数值标黄 */
const disks = mediaDisks.length ? mediaDisks : (q.data?.disks ?? []); function SummaryItem({ label, ok, total, to }: { label: string; ok: number; total: number; to: string }) {
if (!total) return null;
return ( 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 ? ( {q.isPending ? (
<LoadingState rows={2} /> <LoadingState rows={3} />
) : q.isError ? ( ) : q.isError ? (
<ErrorState plain title="存储状态加载失败" onRetry={() => q.refetch()} className="py-6" /> <ErrorState plain title="运行状态加载失败" onRetry={() => q.refetch()} className="py-6" />
) : !disks.length ? ( ) : h ? (
<EmptyState plain icon={HardDrive} title="暂无存储状态" className="py-6" /> <>
) : ( <div className="mb-3 flex items-center gap-2">
<ul className="space-y-4"> {level ? <StatusBadge tone={level.tone}>{h.level === "ok" ? "全部正常" : level.label}</StatusBadge> : null}
{disks.map((d) => { <span className="text-meta text-muted-foreground">检查于 {formatRelative(h.checkedAt)}</span>
const pct = d.usedRatio === null ? null : d.usedRatio * 100; </div>
const state = LEVEL[d.level]; <div className="grid grid-cols-2 gap-3">
return ( {[
<li key={`${d.scope}-${d.id ?? d.path}`} className="min-w-0"> { label: "CPU", v: res?.cpu.percent, key: "cpuPercent" as const },
<div className="flex items-center justify-between gap-2"> { label: "内存", v: res?.memory.percent, key: "memoryPercent" as const },
<p className="min-w-0 truncate text-sm font-medium" title={d.name}> ].map((x) => (
{d.name !== SCOPE[d.scope] ? ( <div key={x.label} className="min-w-0 rounded-lg border border-border/70 px-3 py-2">
<span className="mr-1.5 text-meta font-normal text-muted-foreground">{SCOPE[d.scope]}</span> <p className="flex items-center justify-between text-meta text-muted-foreground">
) : null} {x.label}
{d.name} <span className={cn("text-body font-semibold tabular-nums", usageTone(x.v))}>
</p> {x.v == null ? "—" : `${Math.round(x.v)}%`}
<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>
</span> </span>
<span>已用 {d.usedBytes === null ? "未知" : formatBytes(d.usedBytes, 1)}</span>
<span>总量 {d.totalBytes === null ? "未知" : formatBytes(d.totalBytes, 1)}</span>
</p> </p>
<p className="mt-1 truncate text-meta text-muted-foreground" title={d.path}> {res ? (
{d.path} <Sparkline
</p> samples={res.samples}
{d.error ? <p className="mt-1 text-meta text-warning">无法读取磁盘用量</p> : null} keys={[{ key: x.key, name: x.label }]}
</li> format={(v) => `${v.toFixed(1)}%`}
); max={100}
})} height={32}
</ul> 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> </OverviewCard>
); );
} }
@@ -202,7 +259,7 @@ function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources"
export function HomeRuntime({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) { export function HomeRuntime({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
return ( return (
<div className="mb-4 grid gap-4 lg:grid-cols-2"> <div className="mb-4 grid gap-4 lg:grid-cols-2">
<StorageOverview /> <RuntimeOverview downloaders={downloaders} />
<DownloaderOverview downloaders={downloaders} /> <DownloaderOverview downloaders={downloaders} />
</div> </div>
); );
@@ -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>
);
}
+3 -3
View File
@@ -28,7 +28,7 @@ import { PageHeader } from "~/components/console/page-header.tsx";
import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx"; import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx"; import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
import { runTitle } from "~/components/tasks/labels.ts"; import { runTitle } from "~/components/tasks/labels.ts";
import { useLiveTasks } from "~/components/tasks/task-stream.tsx"; import { useLiveTasks } from "~/components/tasks/live.ts";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts"; import { formatRelative } from "~/lib/labels.ts";
@@ -217,7 +217,7 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
]); ]);
const empty = !downloads.length && !agents.length && !runs.length; const empty = !downloads.length && !agents.length && !runs.length;
return ( return (
<Section title="在忙什么" icon={Activity} to="/console/tasks?tab=active"> <Section title="在忙什么" icon={Activity} to="/console/logs?type=task&level=running">
{running.isPending && !downloads.length ? ( {running.isPending && !downloads.length ? (
<LoadingState rows={3} /> <LoadingState rows={3} />
) : empty ? ( ) : empty ? (
@@ -246,7 +246,7 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) {
))} ))}
{runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => ( {runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => (
<Row key={r.id}> <Row key={r.id}>
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline"> <Link to={`/console/logs?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{runTitle(r)}</span> <span className="block truncate">{runTitle(r)}</span>
</Link> </Link>
</Row> </Row>
+38 -15
View File
@@ -24,6 +24,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
import { TechInfo } from "~/components/console/tech-info.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx";
import { AgentSpendCard, AttentionCard } from "~/components/status/attention-cards.tsx"; import { AgentSpendCard, AttentionCard } from "~/components/status/attention-cards.tsx";
import { HistoryChart } from "~/components/status/history-chart.tsx"; import { HistoryChart } from "~/components/status/history-chart.tsx";
import { ResourceSection } from "~/components/status/resource-section.tsx";
import { StorageSection } from "~/components/status/storage-section.tsx"; import { StorageSection } from "~/components/status/storage-section.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
@@ -35,6 +36,7 @@ export const meta = () => [{ title: "运行状态 · UHD 媒体自动化控制
// 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务;存储节(FIX-5)见 storage-section.tsx。阈值见 docs/runbook/ops.md。 // 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务;存储节(FIX-5)见 storage-section.tsx。阈值见 docs/runbook/ops.md。
// 2026-10-06:顶部加「需要关注」汇总(系统健康逐条列出,铃铛点进来落到 #attention)与智能体用量。 // 2026-10-06:顶部加「需要关注」汇总(系统健康逐条列出,铃铛点进来落到 #attention)与智能体用量。
// 界面第二轮:加「资源」节(CPU / 内存 / 网络 / 磁盘读写与趋势,参考 MP 仪表盘);进程表直接展示并带 CPU 列。
const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = { const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
ok: { label: "正常", tone: "success" }, ok: { label: "正常", tone: "success" },
@@ -250,10 +252,24 @@ function Processes({ d }: { d: SystemHealth }) {
align: "right", align: "right",
cell: (p) => <span className="tabular-nums">{p.sseConnections ?? "—"}</span>, cell: (p) => <span className="tabular-nums">{p.sseConnections ?? "—"}</span>,
}, },
{
key: "cpu",
header: "CPU",
hideBelow: "md",
align: "right",
cell: (p) => {
const cpu = p.extra.cpuPercent;
return (
<span className="tabular-nums" title="两次心跳之间本进程占一个核的百分比">
{typeof cpu === "number" ? `${cpu.toFixed(1)}%` : "—"}
</span>
);
},
},
{ {
key: "mem", key: "mem",
header: "内存", header: "内存",
hideBelow: "lg", hideBelow: "md",
align: "right", align: "right",
cell: (p) => { cell: (p) => {
const rss = p.extra.rssBytes; const rss = p.extra.rssBytes;
@@ -517,6 +533,7 @@ export default function StatusPage() {
<AttentionCard /> <AttentionCard />
<AgentSpendCard /> <AgentSpendCard />
</div> </div>
<ResourceSection />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card title="数据库" icon={Database} level={d.db.level}> <Card title="数据库" icon={Database} level={d.db.level}>
{d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null} {d.db.error ? <p className="mb-2 text-caption text-destructive">{d.db.error}</p> : null}
@@ -542,7 +559,10 @@ export default function StatusPage() {
icon={ListTodo} icon={ListTodo}
level={d.engine.level} level={d.engine.level}
action={ action={
<Link to="/console/tasks?tab=active" className="text-meta text-muted-foreground hover:text-foreground"> <Link
to="/console/logs?type=task&level=running"
className="text-meta text-muted-foreground hover:text-foreground"
>
查看任务 查看任务
</Link> </Link>
} }
@@ -567,8 +587,11 @@ export default function StatusPage() {
icon={Timer} icon={Timer}
level={d.schedules.level} level={d.schedules.level}
action={ action={
<Link to="/console/schedules" className="text-meta text-muted-foreground hover:text-foreground"> <Link
查看计划 to="/console/logs?tab=schedules"
className="text-meta text-muted-foreground hover:text-foreground"
>
查看定时任务
</Link> </Link>
} }
> >
@@ -584,7 +607,7 @@ export default function StatusPage() {
{d.schedules.failing.map((f) => ( {d.schedules.failing.map((f) => (
<li key={f.id} className="min-w-0 text-caption"> <li key={f.id} className="min-w-0 text-caption">
<Link <Link
to={`/console/schedules?job=${encodeURIComponent(f.id)}`} to={`/console/logs?objectType=scheduled_job&objectId=${encodeURIComponent(f.id)}`}
className="font-medium text-foreground hover:underline" className="font-medium text-foreground hover:underline"
> >
{f.name} {f.name}
@@ -600,16 +623,16 @@ export default function StatusPage() {
<Mounts d={d} /> <Mounts d={d} />
</div> </div>
<StorageSection health={d} /> <StorageSection health={d} />
<TechInfo title="进程与趋势" columns={1}> <section className="min-w-0">
<section className="min-w-0"> <h2 className="mb-2 flex items-center gap-2 text-body font-semibold text-foreground">
<h2 className="mb-2 flex items-center gap-2 text-body font-semibold text-foreground"> <Cpu className="h-4 w-4 text-muted-foreground" />
<Cpu className="h-4 w-4 text-muted-foreground" /> 进程
进程 <span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span>
<span className="text-meta font-normal text-muted-foreground">SSE 连接合计 {d.sse.connections}</span> </h2>
</h2> <Processes d={d} />
<Processes d={d} /> <RetiredProcesses rows={d.retiredProcesses} />
<RetiredProcesses rows={d.retiredProcesses} /> </section>
</section> <TechInfo title="事件循环与 SSE 趋势" columns={1}>
<History /> <History />
</TechInfo> </TechInfo>
</div> </div>