- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing, full-width parens around Chinese, single ellipsis, 「」 quotes, period rules) - scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX text (not comments, types, keys, match/compare strings); --fix, --strict, --by-file; wired into deno task ci as check:copy in warn mode - --fix applied to web, server and contracts; files other lanes were editing (subscriptions, sites, browser, agent, auth/account, inventory) left for them - tests updated for the changed messages Co-Authored-By: Claude Opus 5.5 <[email protected]>
717 lines
27 KiB
TypeScript
717 lines
27 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { Dashboard } from "@umt/contracts";
|
|
import {
|
|
Activity,
|
|
AlertTriangle,
|
|
ArrowRight,
|
|
CalendarClock,
|
|
CircleCheck,
|
|
Gauge,
|
|
HardDrive,
|
|
Library as LibraryIcon,
|
|
ListTodo,
|
|
type LucideIcon,
|
|
Pause,
|
|
Play,
|
|
Radio,
|
|
ShieldAlert,
|
|
} from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
import { useRef } from "react";
|
|
import { Link } from "react-router";
|
|
import { toast } from "sonner";
|
|
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
|
|
import { useDecisions } from "~/api/decisions.ts";
|
|
import { useEngineRunsPage, useEngineStats } from "~/api/engine.ts";
|
|
import { useUpdateSchedule } from "~/api/schedules.ts";
|
|
import { useUpdateSettings } from "~/api/settings.ts";
|
|
import { useSystemHealth } from "~/api/system.ts";
|
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
|
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
|
import { StatTile } from "~/components/console/stat-tile.tsx";
|
|
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
|
import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx";
|
|
import { countdown, RUN_STATUS } from "~/components/schedules/labels.ts";
|
|
import { useLiveSchedules, useNow } from "~/components/schedules/schedule-board.tsx";
|
|
import { runStatus, runTitle, workflowLabel } from "~/components/tasks/labels.ts";
|
|
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { Switch } from "~/components/ui/switch.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { decisionKindLabel, formatDuration, formatRelative, healthLabel, spanSec } from "~/lib/labels.ts";
|
|
import { qk } from "~/lib/query-keys.ts";
|
|
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
|
import { cn, formatBytes } from "~/lib/utils.ts";
|
|
|
|
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
|
|
const DASHBOARD_EVENTS = [
|
|
"schedule.changed",
|
|
"task.created",
|
|
"task.status_changed",
|
|
"download.submitted",
|
|
"download.status_changed",
|
|
"download.threshold",
|
|
"organization.changed",
|
|
"agent.run_status",
|
|
"feed.fetched",
|
|
"feed.health_changed",
|
|
"decision.created",
|
|
"decision.updated",
|
|
"settings.changed",
|
|
];
|
|
|
|
function useLiveDashboard() {
|
|
const qc = useQueryClient();
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useSse(DASHBOARD_EVENTS, () => {
|
|
if (timer.current) return;
|
|
timer.current = setTimeout(() => {
|
|
timer.current = null;
|
|
qc.invalidateQueries({ queryKey: qk.dashboard });
|
|
}, 1500);
|
|
});
|
|
return useDashboard();
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
icon: Icon,
|
|
to,
|
|
children,
|
|
className,
|
|
extra,
|
|
}: {
|
|
title: string;
|
|
icon: LucideIcon;
|
|
to?: string;
|
|
children: ReactNode;
|
|
className?: string;
|
|
/** 标题右侧的摘要(计数、状态) */
|
|
extra?: ReactNode;
|
|
}) {
|
|
return (
|
|
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-body font-semibold text-foreground">{title}</h2>
|
|
{extra}
|
|
{to ? (
|
|
<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>
|
|
) : null}
|
|
</div>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Row({ children }: { children: ReactNode }) {
|
|
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
|
|
}
|
|
|
|
// ─── 自动化总开关:显眼状态 + 切换 ───
|
|
function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
|
|
const update = useUpdateSettings();
|
|
const setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
|
|
try {
|
|
await update.mutateAsync({ automation: patch });
|
|
toast.success(ok);
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, "切换失败"));
|
|
throw err;
|
|
}
|
|
};
|
|
const paused = !a.enabled;
|
|
const readOnly = a.readOnlyReconcile;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"pg-slab mb-4 flex flex-col gap-4 rounded-xl p-4 sm:flex-row sm:items-center sm:p-5",
|
|
(paused || readOnly) && "border border-warning/40",
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 flex-1 items-start gap-3">
|
|
<span
|
|
className={cn(
|
|
"grid h-10 w-10 shrink-0 place-items-center rounded-full",
|
|
paused || readOnly ? "bg-warning/10 text-warning" : "bg-success/10 text-success",
|
|
)}
|
|
>
|
|
{readOnly ? (
|
|
<ShieldAlert className="h-5 w-5" />
|
|
) : paused ? (
|
|
<Pause className="h-5 w-5" />
|
|
) : (
|
|
<Play className="h-5 w-5" />
|
|
)}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="text-lead font-semibold text-foreground">
|
|
{paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}
|
|
</p>
|
|
<StatusBadge tone={paused ? "warning" : "success"}>总开关{paused ? "关" : "开"}</StatusBadge>
|
|
{readOnly ? <StatusBadge tone="warning">只读对账</StatusBadge> : null}
|
|
</div>
|
|
<p className="mt-0.5 text-caption text-muted-foreground">
|
|
{paused
|
|
? "自动收录、下载、整理已停止,手动操作仍可用。"
|
|
: readOnly
|
|
? "只核对,不写入。确认无误后关闭。"
|
|
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
|
<div className="flex items-center gap-2 text-caption text-foreground">
|
|
<label htmlFor="home-readonly">只读对账</label>
|
|
<Switch
|
|
id="home-readonly"
|
|
checked={readOnly}
|
|
disabled={update.isPending}
|
|
onCheckedChange={(v) =>
|
|
setAutomation({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账").catch(() => {})
|
|
}
|
|
aria-label="只读对账模式"
|
|
/>
|
|
</div>
|
|
{paused ? (
|
|
<Button
|
|
size="sm"
|
|
disabled={update.isPending}
|
|
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
|
|
>
|
|
<Play />
|
|
恢复自动化
|
|
</Button>
|
|
) : (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="outline" size="sm" disabled={update.isPending}>
|
|
<Pause />
|
|
暂停自动化
|
|
</Button>
|
|
}
|
|
title="暂停全部自动化?"
|
|
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
|
confirmLabel="暂停自动化"
|
|
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 正在进行:任务流 top N ───
|
|
function InProgress({ w }: { w: Dashboard["currentWork"] }) {
|
|
const running = useEngineRunsPage({ status: "running" }, 6, 10_000);
|
|
const stats = useEngineStats();
|
|
const rows = running.data?.items ?? [];
|
|
return (
|
|
<Section title="正在进行" icon={Activity} to="/console/tasks?tab=active">
|
|
{running.isPending ? (
|
|
<LoadingState rows={3} />
|
|
) : running.isError ? (
|
|
<ErrorState plain error={running.error} onRetry={() => running.refetch()} />
|
|
) : rows.length === 0 ? (
|
|
<EmptyState plain icon={CircleCheck} title="没有进行中的任务" className="py-6" />
|
|
) : (
|
|
<ul className="divide-y divide-border/60">
|
|
{rows.slice(0, 5).map((r) => (
|
|
<Row key={r.id}>
|
|
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
|
<span className="block truncate">{runTitle(r)}</span>
|
|
<span className="block truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</span>
|
|
</Link>
|
|
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
|
{formatDuration(spanSec(r))}
|
|
</span>
|
|
</Row>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
|
排队 {stats.data?.byStatus.pending ?? "—"} · 下载中 {w.downloading} · 无进展 {w.stalled} · 入库中 {w.importing}
|
|
</p>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ─── 需要我处理:计数 + 前 3 条 ───
|
|
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
|
|
const list = useDecisions({});
|
|
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
|
|
return (
|
|
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
|
|
{n.total === 0 ? (
|
|
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
|
|
) : (
|
|
<>
|
|
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
|
|
{n.total}
|
|
<span className="ml-1.5 text-caption font-normal text-muted-foreground">
|
|
项 · {n.byKind.map((k) => `${decisionKindLabel(k.kind)} ${k.count}`).join(" · ")}
|
|
</span>
|
|
</p>
|
|
<ul className="divide-y divide-border/60">
|
|
{top.map((d) => (
|
|
<Row key={d.id}>
|
|
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
|
|
<span className="block truncate">{d.title}</span>
|
|
<span className="block truncate text-meta text-muted-foreground">
|
|
{decisionKindLabel(d.kind)} · {formatRelative(d.createdAt)}
|
|
</span>
|
|
</Link>
|
|
</Row>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ─── 媒体概览:作品/季/资产计数 + 版本目标满足率 + 今日产出 ───
|
|
function MediaOverview({ a }: { a: Dashboard["automation"] }) {
|
|
const c = a.slotCounts;
|
|
const total = Object.values(c).reduce((x, y) => x + y, 0);
|
|
const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100);
|
|
const lib = a.library;
|
|
return (
|
|
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<StatTile plain label="作品" value={lib.entities} hint={`电影 ${lib.movies} · 剧集 ${lib.series}`} />
|
|
<StatTile plain label="季" value={lib.seasons} hint={`${lib.episodes} 集`} />
|
|
<StatTile plain label="资产" value={lib.assets} hint="已入库视频" />
|
|
</div>
|
|
<div className="mt-3">
|
|
<div className="flex items-baseline justify-between text-sm">
|
|
<span>版本目标满足率</span>
|
|
<span className="font-semibold tabular-nums">{rate === null ? "—" : `${rate}%`}</span>
|
|
</div>
|
|
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
|
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${rate ?? 0}%` }} />
|
|
</div>
|
|
<p className="mt-1.5 text-meta text-muted-foreground">
|
|
满足 {c.satisfied} / {total} · 需关注 {c.attention} · 下载中 {c.downloading}
|
|
</p>
|
|
</div>
|
|
<p className="mt-auto pt-3 text-meta text-muted-foreground">
|
|
今日 新计划 {a.today.plansCreated} · 提交下载 {a.today.downloadsSubmitted} · 入库文件 {a.today.filesImported} ·
|
|
升级 {a.today.upgrades}
|
|
</p>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ─── 计划带:下一次运行的 5 项 + 计数 + 快速开关 ───
|
|
function PlanStrip() {
|
|
const board = useLiveSchedules();
|
|
const now = useNow(1000);
|
|
const update = useUpdateSchedule();
|
|
const items = board.data?.items ?? [];
|
|
const next = items
|
|
.filter((j) => j.available && j.enabled && j.nextRunAt)
|
|
.sort((x, y) => (x.nextRunAt ?? "").localeCompare(y.nextRunAt ?? ""))
|
|
.slice(0, 5);
|
|
const s = board.data?.stats;
|
|
return (
|
|
<Section
|
|
title="计划"
|
|
icon={CalendarClock}
|
|
to="/console/tasks?tab=plan"
|
|
className="md:col-span-2 xl:col-span-3"
|
|
extra={
|
|
s ? (
|
|
<span className="text-meta text-muted-foreground">
|
|
启用 <span className="font-medium text-foreground tabular-nums">{s.enabled}</span> / {s.total}
|
|
{s.failed ? (
|
|
<>
|
|
{" · "}
|
|
<span className="font-medium text-destructive tabular-nums">上次失败 {s.failed}</span>
|
|
</>
|
|
) : null}
|
|
{board.data && !board.data.schedulerOnline ? <span className="text-warning"> · 调度器未在线</span> : null}
|
|
</span>
|
|
) : null
|
|
}
|
|
>
|
|
{board.isPending ? (
|
|
<LoadingState rows={2} />
|
|
) : board.isError ? (
|
|
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
|
|
) : next.length === 0 ? (
|
|
<EmptyState plain icon={CalendarClock} title="没有待运行的定时任务" className="py-4" />
|
|
) : (
|
|
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
|
{next.map((j) => {
|
|
const last = j.lastStatus ? RUN_STATUS[j.lastStatus] : null;
|
|
return (
|
|
<li key={j.id} className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-border/70 px-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
to={`/console/tasks?tab=plan&job=${encodeURIComponent(j.id)}`}
|
|
className="min-w-0 flex-1 truncate text-sm font-medium hover:underline"
|
|
title={j.name}
|
|
>
|
|
{j.name}
|
|
</Link>
|
|
<Switch
|
|
checked={j.enabled}
|
|
disabled={update.isPending}
|
|
aria-label={`${j.enabled ? "停用" : "启用"}${j.name}`}
|
|
onCheckedChange={(v) =>
|
|
update.mutate(
|
|
{ id: j.id, body: { enabled: v } },
|
|
{
|
|
onSuccess: () => toast.success(v ? `已启用${j.name}` : `已停用${j.name}`),
|
|
onError: (e) => toast.error(errorMessage(e)),
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<p className="flex items-center justify-between gap-2 text-meta text-muted-foreground">
|
|
<span className="tabular-nums">
|
|
{j.runningSince ? "运行中" : j.nextRunAt ? countdown(j.nextRunAt, now) : "—"}
|
|
</span>
|
|
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : null}
|
|
</p>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ─── 最近完成 / 失败 ───
|
|
function RecentRuns() {
|
|
const done = useEngineRunsPage({ status: "completed" }, 6);
|
|
const failed = useEngineRunsPage({ status: "failed" }, 6);
|
|
const rows = [...(done.data?.items ?? []), ...(failed.data?.items ?? [])]
|
|
.sort((x, y) => (y.finishedAt ?? y.createdAt).localeCompare(x.finishedAt ?? x.createdAt))
|
|
.slice(0, 6);
|
|
const loading = done.isPending || failed.isPending;
|
|
const err = done.error ?? failed.error;
|
|
return (
|
|
<Section title="最近完成 / 失败" icon={ListTodo} to="/console/tasks">
|
|
{loading ? (
|
|
<LoadingState rows={3} />
|
|
) : err ? (
|
|
<ErrorState
|
|
plain
|
|
error={err}
|
|
onRetry={() => {
|
|
void done.refetch();
|
|
void failed.refetch();
|
|
}}
|
|
/>
|
|
) : rows.length === 0 ? (
|
|
<EmptyState plain icon={ListTodo} title="还没有结束的任务" className="py-6" />
|
|
) : (
|
|
<ul className="divide-y divide-border/60">
|
|
{rows.map((r) => {
|
|
const s = runStatus(r.status);
|
|
return (
|
|
<Row key={r.id}>
|
|
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
|
|
<span className="block truncate">{runTitle(r)}</span>
|
|
<span className="block truncate text-meta text-muted-foreground">
|
|
{workflowLabel(r.workflowName)} · {formatRelative(r.finishedAt)}
|
|
</span>
|
|
</Link>
|
|
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
|
</Row>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ─── 来源健康:站点(含账号状态)+ Feed / 下载器 / 媒体服务器,有问题的排前 ───
|
|
const ACCOUNT: Record<string, { label: string; tone: StatusTone }> = {
|
|
unconfigured: { label: "未配置账号", tone: "neutral" },
|
|
unknown: { label: "账号未验证", tone: "neutral" },
|
|
active: { label: "账号正常", tone: "success" },
|
|
invalid: { label: "账号失效", tone: "destructive" },
|
|
expired: { label: "登录过期", tone: "warning" },
|
|
challenge: { label: "需人工验证", tone: "warning" },
|
|
};
|
|
|
|
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
|
|
const sites = useSitesWithAccounts();
|
|
const siteRows = (sites.data?.items ?? s.sites).map((x) => {
|
|
const acc = "accountStatus" in x && x.accountStatus ? ACCOUNT[x.accountStatus] : undefined;
|
|
return {
|
|
id: x.id,
|
|
kind: "站点",
|
|
name: x.name,
|
|
enabled: x.enabled,
|
|
health: "healthStatus" in x ? x.healthStatus : x.health,
|
|
account: acc,
|
|
to: `/console/sites/${x.id}`,
|
|
};
|
|
});
|
|
const rows = [
|
|
...siteRows,
|
|
...s.feeds.map((x) => ({
|
|
id: x.id,
|
|
kind: "Feed",
|
|
name: x.name,
|
|
enabled: x.enabled,
|
|
health: x.health,
|
|
account: undefined,
|
|
to: consolePath("sources"),
|
|
})),
|
|
...s.downloaders.map((x) => ({
|
|
id: x.id,
|
|
kind: "下载器",
|
|
name: x.name,
|
|
enabled: x.enabled,
|
|
health: x.health,
|
|
account: undefined,
|
|
to: settingsPath("downloaders"),
|
|
})),
|
|
];
|
|
const bad = (r: (typeof rows)[number]) =>
|
|
(r.enabled && healthLabel(r.health).tone !== "success" ? 2 : 0) +
|
|
(r.account && r.account.tone !== "success" ? 1 : 0);
|
|
rows.sort((x, y) => bad(y) - bad(x));
|
|
const accountIssues = siteRows.filter(
|
|
(r) => r.account && (r.account.tone === "destructive" || r.account.tone === "warning"),
|
|
).length;
|
|
return (
|
|
<Section title="来源健康" icon={Radio} to={consolePath("sites")}>
|
|
{rows.length === 0 ? (
|
|
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 Feed 后显示。" className="py-6" />
|
|
) : (
|
|
<>
|
|
{accountIssues ? <p className="mb-1 text-meta text-warning">{accountIssues} 个站点的账号需要处理</p> : null}
|
|
<ul className="divide-y divide-border/60">
|
|
{rows.slice(0, 7).map((r) => {
|
|
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
|
|
return (
|
|
<Row key={`${r.kind}-${r.id}`}>
|
|
<Link to={r.to} className="min-w-0 flex-1 hover:underline">
|
|
<span className="block truncate">
|
|
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
|
|
{r.name}
|
|
</span>
|
|
{r.account ? (
|
|
<span
|
|
className={cn(
|
|
"block truncate text-meta",
|
|
r.account.tone === "destructive"
|
|
? "text-destructive"
|
|
: r.account.tone === "warning"
|
|
? "text-warning"
|
|
: "text-muted-foreground",
|
|
)}
|
|
>
|
|
{r.account.label}
|
|
</span>
|
|
) : null}
|
|
</Link>
|
|
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
|
</Row>
|
|
);
|
|
})}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ─── 运行状态摘要(/system/health) ───
|
|
const LEVEL: Record<string, { label: string; tone: StatusTone }> = {
|
|
ok: { label: "正常", tone: "success" },
|
|
warn: { label: "需要关注", tone: "warning" },
|
|
critical: { label: "需要处理", tone: "destructive" },
|
|
unknown: { label: "未知", tone: "neutral" },
|
|
};
|
|
|
|
function StatusSummary() {
|
|
const h = useSystemHealth(30_000);
|
|
const d = h.data;
|
|
const stale = d?.processes.filter((p) => p.stale) ?? [];
|
|
const alive = (d?.processes.length ?? 0) - stale.length;
|
|
const mountsBad = d?.mounts.filter((m) => m.status !== "online").length ?? 0;
|
|
const lv = LEVEL[d?.level ?? "unknown"] ?? LEVEL.unknown;
|
|
return (
|
|
<Section
|
|
title="运行状态"
|
|
icon={Gauge}
|
|
to={consolePath("status")}
|
|
className="md:col-span-2 xl:col-span-3"
|
|
extra={d ? <StatusBadge tone={lv.tone}>{lv.label}</StatusBadge> : null}
|
|
>
|
|
{h.isPending ? (
|
|
<LoadingState rows={1} />
|
|
) : h.isError ? (
|
|
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
|
|
) : d ? (
|
|
<div className="flex flex-wrap gap-x-6 gap-y-1.5 text-sm">
|
|
<span>
|
|
进程 <span className="font-medium tabular-nums">{alive}</span> 在线
|
|
{stale.length ? (
|
|
<span className="text-warning">
|
|
{" "}
|
|
· {stale.length} 个无心跳({stale.map((p) => `${p.processKind} ${p.pid}`).join("、")})
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span>
|
|
待执行 <span className="font-medium tabular-nums">{d.engine.pending}</span>
|
|
{d.engine.oldestPendingAgeSec ? ` · 最久 ${formatDuration(d.engine.oldestPendingAgeSec)}` : ""}
|
|
</span>
|
|
<span>
|
|
24 小时失败率{" "}
|
|
<span className="font-medium tabular-nums">
|
|
{d.engine.failureRate24h === null ? "—" : `${(d.engine.failureRate24h * 100).toFixed(1)}%`}
|
|
</span>
|
|
</span>
|
|
<span>
|
|
数据库{" "}
|
|
<span className="font-medium tabular-nums">{d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒`}</span>
|
|
</span>
|
|
{mountsBad ? <span className="text-destructive">{mountsBad} 个挂载根不可用</span> : null}
|
|
</div>
|
|
) : null}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
|
const volumeLabel = useVolumeLabel();
|
|
return (
|
|
<Section title="缓存与保留" icon={HardDrive} to={consolePath("downloads")}>
|
|
{c.volumes.length === 0 ? (
|
|
<EmptyState
|
|
plain
|
|
icon={HardDrive}
|
|
title="还没有下载器"
|
|
description="添加下载器后显示缓存水位。"
|
|
className="py-6"
|
|
/>
|
|
) : (
|
|
<ul className="space-y-4">
|
|
{c.volumes.map((v) => {
|
|
const pct = v.usedPercent;
|
|
const over = pct !== null && pct >= v.hardThresholdPercent;
|
|
const near = pct !== null && pct >= v.resumePercent;
|
|
return (
|
|
<li key={v.volumeKey} className="min-w-0">
|
|
<div className="flex items-center justify-between gap-2 text-sm">
|
|
<span className="truncate font-medium">{volumeLabel(v)}</span>
|
|
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
|
|
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
|
|
</StatusBadge>
|
|
</div>
|
|
<div className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
|
<div
|
|
className={cn(
|
|
"h-full rounded-full",
|
|
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
|
|
)}
|
|
style={{ width: `${pct ?? 0}%` }}
|
|
/>
|
|
<span
|
|
aria-hidden
|
|
className="absolute inset-y-0 w-px bg-destructive"
|
|
style={{ left: `${v.hardThresholdPercent}%` }}
|
|
/>
|
|
</div>
|
|
<p className="mt-1.5 text-meta text-muted-foreground">
|
|
阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "}
|
|
{formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)}
|
|
{v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}
|
|
</p>
|
|
{v.notes.map((n) => (
|
|
<p key={n} className="text-meta text-muted-foreground">
|
|
{n}
|
|
</p>
|
|
))}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
<Link
|
|
to={settingsPath("automation", "retention")}
|
|
className="mt-3 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
|
>
|
|
调整阈值与保留天数
|
|
</Link>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function LiveIndicator() {
|
|
const s = useSseStatus();
|
|
const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral";
|
|
const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中";
|
|
return <StatusBadge tone={tone}>{label}</StatusBadge>;
|
|
}
|
|
|
|
export default function Home() {
|
|
const q = useLiveDashboard();
|
|
useLiveTasks();
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="首页"
|
|
description="正在进行的任务、需要处理的事项、计划和最近结果。"
|
|
actions={
|
|
<div className="flex items-center gap-2">
|
|
<LiveIndicator />
|
|
{q.data ? (
|
|
<span className="hidden text-meta text-muted-foreground sm:inline">
|
|
更新于 {formatRelative(q.data.generatedAt)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
}
|
|
/>
|
|
{q.isPending ? (
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{["a", "b", "c", "d", "e", "f"].map((k) => (
|
|
<div key={k} className="pg-slab rounded-xl p-5">
|
|
<LoadingState rows={3} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : q.isError ? (
|
|
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
|
|
) : (
|
|
<>
|
|
<AutomationBanner a={q.data.automation} />
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
<InProgress w={q.data.currentWork} />
|
|
<NeedsMe n={q.data.needsAttention} />
|
|
<MediaOverview a={q.data.automation} />
|
|
<PlanStrip />
|
|
<RecentRuns />
|
|
<CacheSection c={q.data.cache} />
|
|
<SourcesSection s={q.data.sources} />
|
|
<StatusSummary />
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|