- /login: setup/login/TOTP states driven by server error codes, fixed-height error slot - console layout: GET /auth/session gate, redirect to /login, account popover (change password, logout) - home: six dashboard sections with SSE-driven refresh, automation switch and read-only reconcile toggle - settings tabs: downloaders, media servers, library roots, path mappings, site credentials, API keys (one-time plaintext), Pi upstream, cache/organize/retention/budget, automation - engine: runs list (status/name filters, cursor paging, stats tiles), run detail (step timeline, json payloads, signals, cancel) - audit: filterable list with detail sheet - vite: allow repo root in server.fs so deno-installed deps under node_modules/.deno load in dev Co-Authored-By: Claude Opus 5.5 <[email protected]>
516 lines
18 KiB
TypeScript
516 lines
18 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
||
import type { Dashboard } from "@umt/contracts";
|
||
import {
|
||
Activity,
|
||
AlertTriangle,
|
||
ArrowRight,
|
||
CircleCheck,
|
||
HardDrive,
|
||
ListTodo,
|
||
type LucideIcon,
|
||
Pause,
|
||
Play,
|
||
Radio,
|
||
ShieldAlert,
|
||
Sparkles,
|
||
} from "lucide-react";
|
||
import type { ReactNode } from "react";
|
||
import { useRef } from "react";
|
||
import { Link } from "react-router";
|
||
import { toast } from "sonner";
|
||
import { useDashboard } from "~/api/dashboard.ts";
|
||
import { useUpdateSettings } from "~/api/settings.ts";
|
||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||
import { consolePath } from "~/components/console/nav.ts";
|
||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { Switch } from "~/components/ui/switch.tsx";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import {
|
||
agentRunLabel,
|
||
decisionKindLabel,
|
||
downloadLabel,
|
||
formatRelative,
|
||
healthLabel,
|
||
organizationActionLabel,
|
||
organizationLabel,
|
||
} 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 = [
|
||
"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,
|
||
}: {
|
||
title: string;
|
||
icon: LucideIcon;
|
||
to?: string;
|
||
children: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
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>
|
||
{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>
|
||
);
|
||
}
|
||
|
||
function Overview({ a }: { a: Dashboard["automation"] }) {
|
||
const t = a.today;
|
||
return (
|
||
<Section title="自动化概览" icon={Sparkles} to={consolePath("subscriptions")}>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<StatTile plain label="今日新计划" value={t.plansCreated} />
|
||
<StatTile plain label="今日提交下载" value={t.downloadsSubmitted} />
|
||
<StatTile plain label="今日入库文件" value={t.filesImported} />
|
||
<StatTile plain label="今日升级" value={t.upgrades} />
|
||
</div>
|
||
<p className="mt-3 text-meta text-muted-foreground">
|
||
整理成功 {t.recordsSucceeded} · 失败 {t.recordsFailed} · 满足的版本目标 {a.slotCounts.satisfied} · 需关注{" "}
|
||
{a.slotCounts.attention}
|
||
</p>
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function CurrentWork({ w }: { w: Dashboard["currentWork"] }) {
|
||
const counts = [
|
||
{ label: "下载中", v: w.downloading },
|
||
{ label: "已暂停", v: w.paused },
|
||
{ label: "无进展", v: w.stalled },
|
||
{ label: "入库中", v: w.importing },
|
||
];
|
||
const idle = counts.every((c) => c.v === 0) && w.items.length === 0 && w.agentRuns.length === 0;
|
||
return (
|
||
<Section title="当前工作" icon={Activity} to={consolePath("tasks")}>
|
||
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
|
||
{counts.map((c) => (
|
||
<span key={c.label}>
|
||
{c.label} <span className="font-medium text-foreground tabular-nums">{c.v}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
{idle ? (
|
||
<EmptyState plain icon={CircleCheck} title="没有进行中的工作" className="py-6" />
|
||
) : (
|
||
<ul className="divide-y divide-border/60">
|
||
{w.items.slice(0, 5).map((it) => {
|
||
const s = downloadLabel(it.status);
|
||
return (
|
||
<Row key={it.downloadTaskId}>
|
||
<Link to={consolePath("tasks", it.downloadTaskId)} className="min-w-0 flex-1 truncate hover:underline">
|
||
{it.name}
|
||
</Link>
|
||
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
|
||
{Math.round(it.progress * 100)}%
|
||
</span>
|
||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||
</Row>
|
||
);
|
||
})}
|
||
{w.agentRuns.slice(0, 3).map((r) => {
|
||
const s = agentRunLabel(r.status);
|
||
return (
|
||
<Row key={r.id}>
|
||
<Link to={consolePath("agent", r.id)} className="min-w-0 flex-1 truncate hover:underline">
|
||
Agent:{r.goal}
|
||
</Link>
|
||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||
</Row>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
|
||
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">项待处理</span>
|
||
</p>
|
||
<ul className="divide-y divide-border/60">
|
||
{n.byKind.map((k) => (
|
||
<Row key={k.kind}>
|
||
<span className="truncate">{decisionKindLabel(k.kind)}</span>
|
||
<span className="text-meta text-muted-foreground tabular-nums">{k.count}</span>
|
||
</Row>
|
||
))}
|
||
</ul>
|
||
</>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
||
return (
|
||
<Section title="缓存与保留" icon={HardDrive} to={consolePath("settings")}>
|
||
{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">{v.volumeKey}</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>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
|
||
const rows = [
|
||
...s.sites.map((x) => ({
|
||
id: x.id,
|
||
kind: "站点",
|
||
name: x.name,
|
||
enabled: x.enabled,
|
||
health: x.health,
|
||
at: x.lastSnapshotAt,
|
||
err: null as string | null,
|
||
})),
|
||
...s.feeds.map((x) => ({
|
||
id: x.id,
|
||
kind: "Feed",
|
||
name: x.name,
|
||
enabled: x.enabled,
|
||
health: x.health,
|
||
at: x.lastSuccessAt,
|
||
err: x.lastError,
|
||
})),
|
||
...s.downloaders.map((x) => ({
|
||
id: x.id,
|
||
kind: "下载器",
|
||
name: x.name,
|
||
enabled: x.enabled,
|
||
health: x.health,
|
||
at: x.lastSeenAt,
|
||
err: null,
|
||
})),
|
||
...s.mediaServers.map((x) => ({
|
||
id: x.id,
|
||
kind: "媒体服务器",
|
||
name: x.name,
|
||
enabled: x.enabled,
|
||
health: x.health,
|
||
at: x.lastSeenAt,
|
||
err: null,
|
||
})),
|
||
];
|
||
return (
|
||
<Section title="来源健康" icon={Radio} to={consolePath("sources")}>
|
||
{rows.length === 0 ? (
|
||
<EmptyState
|
||
plain
|
||
icon={Radio}
|
||
title="还没有来源"
|
||
description="在系统设置或来源页登记站点、Feed 与实例。"
|
||
className="py-6"
|
||
/>
|
||
) : (
|
||
<ul className="divide-y divide-border/60">
|
||
{rows.slice(0, 8).map((r) => {
|
||
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
|
||
return (
|
||
<Row key={`${r.kind}-${r.id}`}>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate">
|
||
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
|
||
{r.name}
|
||
</p>
|
||
{r.err ? <p className="truncate text-meta text-destructive">{r.err}</p> : null}
|
||
</div>
|
||
<span className="hidden shrink-0 text-meta text-muted-foreground sm:inline">
|
||
{formatRelative(r.at)}
|
||
</span>
|
||
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
|
||
</Row>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</Section>
|
||
);
|
||
}
|
||
|
||
function RecentOutput({ items }: { items: Dashboard["recentOutput"] }) {
|
||
return (
|
||
<Section title="最近产出" icon={ListTodo} to={consolePath("organize")}>
|
||
{items.length === 0 ? (
|
||
<EmptyState plain icon={ListTodo} title="还没有入库记录" className="py-6" />
|
||
) : (
|
||
<ul className="divide-y divide-border/60">
|
||
{items.slice(0, 6).map((r) => {
|
||
const s = organizationLabel(r.status);
|
||
return (
|
||
<li key={r.recordId} className="min-w-0 py-2">
|
||
<div className="flex items-center justify-between gap-3 text-sm">
|
||
<Link to={consolePath("organize", r.recordId)} className="min-w-0 truncate hover:underline">
|
||
{r.title ?? "未命名"}
|
||
</Link>
|
||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||
</div>
|
||
<p className="truncate text-meta text-muted-foreground">
|
||
{organizationActionLabel(r.action)} · {r.counts.succeeded}/{r.counts.total} 个文件 ·{" "}
|
||
{formatRelative(r.finishedAt)}
|
||
{r.targetPath ? ` · ${r.targetPath}` : ""}
|
||
</p>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</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();
|
||
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">
|
||
<Overview a={q.data.automation} />
|
||
<CurrentWork w={q.data.currentWork} />
|
||
<NeedsAttention n={q.data.needsAttention} />
|
||
<CacheSection c={q.data.cache} />
|
||
<SourcesSection s={q.data.sources} />
|
||
<RecentOutput items={q.data.recentOutput} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|