- Nav overview group: 首页 / 任务 / 计划 / Agent 会话; tasks page drops the plan tab - /console/tasks?tab=plan[&job=] redirects to /console/schedules[?job=] - Plan page: 自动任务 compact grouped list (switch, interval preset/custom, last result, countdown, failure reason, run now/skip/run records) and Agent 巡检 cards (goal, allowlist, budget, upstream/model, trigger, cost per run, recent sessions); split stats - Home plan band: two counts linking to #auto / #checks - Docs: console-ia §7.4a, glossary, schedules runbook; smoke routes Co-Authored-By: Claude Opus 5.5 <[email protected]>
719 lines
26 KiB
TypeScript
719 lines
26 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { Dashboard } from "@umt/contracts";
|
|
import {
|
|
Activity,
|
|
AlertTriangle,
|
|
ArrowRight,
|
|
Bot,
|
|
CalendarClock,
|
|
CalendarCog,
|
|
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 { useScheduleAgentRuns } from "~/api/agent.ts";
|
|
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
|
|
import { useDecisions } from "~/api/decisions.ts";
|
|
import { useEngineRunsPage, useEngineStats } from "~/api/engine.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 { formatUsd, planPath } from "~/components/schedules/labels.ts";
|
|
import { planStats, useLiveSchedules, useNow } from "~/components/schedules/schedule-board.tsx";
|
|
import { engineRunStatus, 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>
|
|
);
|
|
}
|
|
|
|
// ─── 计划带:自动任务与巡检分别计数,链到计划页对应节 ───
|
|
function PlanCount({
|
|
to,
|
|
label,
|
|
icon: Icon,
|
|
children,
|
|
}: {
|
|
to: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<Link
|
|
to={to}
|
|
className="flex min-w-0 flex-col gap-1 rounded-xl border border-border/70 px-3 py-2.5 transition-colors duration-micro hover:bg-foreground/[0.04]"
|
|
>
|
|
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{label}
|
|
</span>
|
|
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-caption text-muted-foreground">
|
|
{children}
|
|
</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function Num({ children, tone }: { children: ReactNode; tone?: "destructive" }) {
|
|
return (
|
|
<span className={cn("text-lead font-semibold tabular-nums", tone ? "text-destructive" : "text-foreground")}>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function PlanStrip() {
|
|
const board = useLiveSchedules();
|
|
const runs = useScheduleAgentRuns();
|
|
const now = useNow(60_000);
|
|
const s = planStats(
|
|
board.data?.items ?? [],
|
|
(runs.data ?? []).map((r) => ({ at: r.createdAt, cost: r.usage.costUsd })),
|
|
now,
|
|
);
|
|
return (
|
|
<Section
|
|
title="计划"
|
|
icon={CalendarCog}
|
|
to={planPath()}
|
|
extra={
|
|
board.data && !board.data.schedulerOnline ? <span className="text-meta text-warning">调度器未在线</span> : null
|
|
}
|
|
>
|
|
{board.isPending ? (
|
|
<LoadingState rows={2} />
|
|
) : board.isError ? (
|
|
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<PlanCount to={planPath({ section: "auto" })} label="自动任务" icon={CalendarClock}>
|
|
<span>
|
|
启用 <Num>{s.auto.enabled}</Num> / {s.auto.total}
|
|
</span>
|
|
<span>
|
|
失败 <Num tone={s.auto.failed ? "destructive" : undefined}>{s.auto.failed}</Num>
|
|
</span>
|
|
</PlanCount>
|
|
<PlanCount to={planPath({ section: "checks" })} label="Agent 巡检" icon={Bot}>
|
|
<span>
|
|
启用 <Num>{s.checks.enabled}</Num> / {s.checks.total}
|
|
</span>
|
|
<span title="近 7 天巡检会话费用合计">
|
|
近 7 天 <Num>{runs.isPending ? "…" : runs.isError ? "—" : formatUsd(s.checks.weekCost)}</Num>
|
|
</span>
|
|
</PlanCount>
|
|
</div>
|
|
)}
|
|
</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 = engineRunStatus(r);
|
|
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>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|