Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
267 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|