diff --git a/apps/web/app/api/system.ts b/apps/web/app/api/system.ts index 947801c5..505ef1ef 100644 --- a/apps/web/app/api/system.ts +++ b/apps/web/app/api/system.ts @@ -4,6 +4,7 @@ import type { StorageCleanupTarget, SystemHealth, SystemHealthHistory, + SystemResources, SystemStorage, } from "@umt/contracts"; import { apiData } from "~/lib/api.ts"; @@ -50,3 +51,12 @@ export function useStorageCleanup() { 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("/system/resources", { params: { minutes }, signal }), + refetchInterval: refetchMs, + }); +} diff --git a/apps/web/app/components/dashboard/home-runtime.test.ts b/apps/web/app/components/dashboard/home-runtime.test.ts new file mode 100644 index 00000000..c20a89f5 --- /dev/null +++ b/apps/web/app/components/dashboard/home-runtime.test.ts @@ -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 => + ({ + 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(/下载器]*>1\/2/); + expect(html).toMatch(/站点]*>1\/2/); + expect(html).toMatch(/媒体库目录]*>1\/2/); + }); + + it("全部正常时写「全部正常」,没有配置的项不列", () => { + const html = render(health({ level: "ok", mounts: [] }), []); + expect(html).toContain("全部正常"); + expect(html).not.toContain("媒体库目录<"); + expect(html).not.toContain("下载器 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 ( - + + + {label} + + {ok}/{total} + + + ); +} + +/** + * 运行状态简版(界面第二轮,参考 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 ( + {q.isPending ? ( - + ) : q.isError ? ( - q.refetch()} className="py-6" /> - ) : !disks.length ? ( - - ) : ( -
    - {disks.map((d) => { - const pct = d.usedRatio === null ? null : d.usedRatio * 100; - const state = LEVEL[d.level]; - return ( -
  • -
    -

    - {d.name !== SCOPE[d.scope] ? ( - {SCOPE[d.scope]} - ) : null} - {d.name} -

    - - {state.label} - {pct === null ? "" : ` · ${Math.round(pct)}%`} - -
    - {pct !== null ? ( -
    -
    -
    - ) : null} -

    - - 剩余{" "} - - {d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)} - + q.refetch()} className="py-6" /> + ) : h ? ( + <> +

    + {level ? {h.level === "ok" ? "全部正常" : level.label} : null} + 检查于 {formatRelative(h.checkedAt)} +
    +
    + {[ + { label: "CPU", v: res?.cpu.percent, key: "cpuPercent" as const }, + { label: "内存", v: res?.memory.percent, key: "memoryPercent" as const }, + ].map((x) => ( +
    +

    + {x.label} + + {x.v == null ? "—" : `${Math.round(x.v)}%`} - 已用 {d.usedBytes === null ? "未知" : formatBytes(d.usedBytes, 1)} - 总量 {d.totalBytes === null ? "未知" : formatBytes(d.totalBytes, 1)}

    -

    - {d.path} -

    - {d.error ?

    无法读取磁盘用量

    : null} -
  • - ); - })} -
+ {res ? ( + `${v.toFixed(1)}%`} + max={100} + height={32} + label={`${x.label}占用趋势(最近 30 分钟)`} + /> + ) : ( +
+ )} +
+ ))} + + {disks.length ? ( +
    + {disks.map((d) => { + const pct = (d.usedRatio ?? 0) * 100; + return ( +
  • +
    + + {SCOPE[d.scope]} + {d.name} + + + {Math.round(pct)}% · 剩余{" "} + {d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)} + +
    +
    +
    +
    +
  • + ); + })} +
+ ) : null} +

+ + + +

+ + ) : ( + )} - {q.data && !q.isError ? ( -

所在磁盘用量 · 检查于 {formatRelative(q.data.checkedAt)}

- ) : null}
); } @@ -202,7 +259,7 @@ function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources" export function HomeRuntime({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) { return (
- +
); diff --git a/apps/web/app/components/status/resource-section.tsx b/apps/web/app/components/status/resource-section.tsx new file mode 100644 index 00000000..eb614cdd --- /dev/null +++ b/apps/web/app/components/status/resource-section.tsx @@ -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 = { 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()} /> + ) : ( + + )} +
+ ); +} diff --git a/apps/web/app/routes/console/home.tsx b/apps/web/app/routes/console/home.tsx index c531eeb3..b0ef6169 100644 --- a/apps/web/app/routes/console/home.tsx +++ b/apps/web/app/routes/console/home.tsx @@ -28,7 +28,7 @@ import { PageHeader } from "~/components/console/page-header.tsx"; import { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx"; import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx"; 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 { errorMessage } from "~/lib/api.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; return ( -
+
{running.isPending && !downloads.length ? ( ) : empty ? ( @@ -246,7 +246,7 @@ function Busy({ w }: { w: Dashboard["currentWork"] }) { ))} {runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => ( - + {runTitle(r)} diff --git a/apps/web/app/routes/console/status.tsx b/apps/web/app/routes/console/status.tsx index 3ae511fa..7dc1d24b 100644 --- a/apps/web/app/routes/console/status.tsx +++ b/apps/web/app/routes/console/status.tsx @@ -24,6 +24,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge. import { TechInfo } from "~/components/console/tech-info.tsx"; import { AgentSpendCard, AttentionCard } from "~/components/status/attention-cards.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 { Button } from "~/components/ui/button.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。 // 2026-10-06:顶部加「需要关注」汇总(系统健康逐条列出,铃铛点进来落到 #attention)与智能体用量。 +// 界面第二轮:加「资源」节(CPU / 内存 / 网络 / 磁盘读写与趋势,参考 MP 仪表盘);进程表直接展示并带 CPU 列。 const LEVEL: Record = { ok: { label: "正常", tone: "success" }, @@ -250,10 +252,24 @@ function Processes({ d }: { d: SystemHealth }) { align: "right", cell: (p) => {p.sseConnections ?? "—"}, }, + { + key: "cpu", + header: "CPU", + hideBelow: "md", + align: "right", + cell: (p) => { + const cpu = p.extra.cpuPercent; + return ( + + {typeof cpu === "number" ? `${cpu.toFixed(1)}%` : "—"} + + ); + }, + }, { key: "mem", header: "内存", - hideBelow: "lg", + hideBelow: "md", align: "right", cell: (p) => { const rss = p.extra.rssBytes; @@ -517,6 +533,7 @@ export default function StatusPage() { +
{d.db.error ?

{d.db.error}

: null} @@ -542,7 +559,10 @@ export default function StatusPage() { icon={ListTodo} level={d.engine.level} action={ - + 查看任务 } @@ -567,8 +587,11 @@ export default function StatusPage() { icon={Timer} level={d.schedules.level} action={ - - 查看计划 + + 查看定时任务 } > @@ -584,7 +607,7 @@ export default function StatusPage() { {d.schedules.failing.map((f) => (
  • {f.name} @@ -600,16 +623,16 @@ export default function StatusPage() {
  • - -
    -

    - - 进程 - SSE 连接合计 {d.sse.connections} -

    - - -
    +
    +

    + + 进程 + SSE 连接合计 {d.sse.connections} +

    + + +
    +