Files
UHD_Media_Tool/apps/web/app/components/dashboard/home-runtime.tsx
T

267 lines
12 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { type Dashboard, DownloadBoard, type HealthLevel } from "@umt/contracts";
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, 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";
const LEVEL: Record<HealthLevel, { label: string; tone: StatusTone }> = {
ok: { label: "正常", tone: "success" },
warn: { label: "需要关注", tone: "warning" },
critical: { label: "需要处理", tone: "destructive" },
unknown: { label: "用量未知", tone: "neutral" },
};
const SCOPE = { data_dir: "系统数据", library_root: "媒体库", download_dir: "下载目录" };
function OverviewCard({
title,
icon: Icon,
to,
children,
}: {
title: string;
icon: LucideIcon;
to: string;
children: ReactNode;
}) {
return (
<section className="pg-slab min-w-0 rounded-xl p-5">
<div className="mb-3 flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
<h2 className="text-body font-semibold text-foreground">{title}</h2>
<Link
to={to}
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
>
详情
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
{children}
</section>
);
}
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 (
<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={3} />
) : q.isError ? (
<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>
</p>
{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" />
)}
</OverviewCard>
);
}
function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
// 首页仅取一条面板记录,速度和计数仍是完整汇总;无需拉取下载任务列表。
const q = useQuery({
queryKey: ["downloads", "home-overview"],
queryFn: ({ signal }) =>
apiParsed(DownloadBoard, "/api/v1/download-tasks/board", { params: { state: "active", limit: 1 }, signal }),
refetchInterval: 15_000,
refetchIntervalInBackground: false,
});
const board = q.data;
const instances = board?.transfer.instances ?? [];
const sources =
downloaders ??
instances.map((d) => ({ id: d.instanceId, name: d.name, enabled: true, health: "unknown", lastSeenAt: null }));
return (
<OverviewCard title="下载器情况" icon={Download} to={consolePath("downloads")}>
{q.isPending ? (
<LoadingState rows={2} />
) : q.isError ? (
<ErrorState plain title="下载器状态加载失败" onRetry={() => q.refetch()} className="py-6" />
) : !sources.length ? (
<EmptyState plain icon={Download} title={downloaders ? "尚未配置下载器" : "暂无启用下载器"} className="py-6" />
) : (
<>
<p className="mb-3 text-title-sm font-semibold tabular-nums">
{board?.counts.active}
<span className="ml-1.5 text-caption font-normal text-muted-foreground">项进行中</span>
</p>
<ul className="divide-y divide-border/60">
{sources.map((d) => {
const live = instances.find((i) => i.instanceId === d.id);
const state = !d.enabled
? { label: "已停用", tone: "neutral" as const }
: !live
? { label: "状态未知", tone: "neutral" as const }
: live.reachable
? { label: "在线", tone: "success" as const }
: { label: "连接失败", tone: "warning" as const };
const readable = d.enabled && live?.reachable;
return (
<li key={d.id} className="py-2 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm font-medium" title={d.name}>
{d.name}
</span>
<StatusBadge tone={state.tone}>{state.label}</StatusBadge>
</div>
{d.enabled ? (
<p className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground tabular-nums">
<span>
下载{" "}
<span className="text-foreground">
{readable ? `${formatBytes(live.dlSpeed, 1)}/s` : "未知"}
</span>
</span>
<span>
上传{" "}
<span className="text-foreground">
{readable ? `${formatBytes(live.upSpeed, 1)}/s` : "未知"}
</span>
</span>
</p>
) : null}
</li>
);
})}
</ul>
<p className="mt-3 text-meta text-muted-foreground">
进行中含等待计划、暂停和异常任务 · 速度含下载器全部任务
</p>
{board?.liveAt ? (
<p className="mt-1 text-meta text-muted-foreground">速度采样于 {formatRelative(board.liveAt)}</p>
) : null}
</>
)}
</OverviewCard>
);
}
export function HomeRuntime({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
return (
<div className="mb-4 grid gap-4 lg:grid-cols-2">
<RuntimeOverview downloaders={downloaders} />
<DownloaderOverview downloaders={downloaders} />
</div>
);
}