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 | 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 (

{title}

{extra} {to ? ( 查看 ) : null}
{children}
); } function Row({ children }: { children: ReactNode }) { return
  • {children}
  • ; } // ─── 自动化总开关:显眼状态 + 切换 ─── 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 (
    {readOnly ? ( ) : paused ? ( ) : ( )}

    {paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}

    总开关{paused ? "关" : "开"} {readOnly ? 只读对账 : null}

    {paused ? "自动收录、下载、整理已停止,手动操作仍可用。" : readOnly ? "只核对,不写入。确认无误后关闭。" : `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}

    setAutomation({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账").catch(() => {}) } aria-label="只读对账模式" />
    {paused ? ( ) : ( 暂停自动化 } title="暂停全部自动化?" description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。" confirmLabel="暂停自动化" onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")} /> )}
    ); } // ─── 正在进行:任务流 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 (
    {running.isPending ? ( ) : running.isError ? ( running.refetch()} /> ) : rows.length === 0 ? ( ) : (
      {rows.slice(0, 5).map((r) => ( {runTitle(r)} {workflowLabel(r.workflowName)} {formatDuration(spanSec(r))} ))}
    )}

    排队 {stats.data?.byStatus.pending ?? "—"} · 下载中 {w.downloading} · 无进展 {w.stalled} · 入库中 {w.importing}

    ); } // ─── 需要我处理:计数 + 前 3 条 ─── function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) { const list = useDecisions({}); const top = (list.data?.pages[0]?.items ?? []).slice(0, 3); return (
    {n.total === 0 ? ( ) : ( <>

    {n.total} 项 · {n.byKind.map((k) => `${decisionKindLabel(k.kind)} ${k.count}`).join(" · ")}

      {top.map((d) => ( {d.title} {decisionKindLabel(d.kind)} · {formatRelative(d.createdAt)} ))}
    )}
    ); } // ─── 媒体概览:作品/季/资产计数 + 版本目标满足率 + 今日产出 ─── 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 (
    版本目标满足率 {rate === null ? "—" : `${rate}%`}

    满足 {c.satisfied} / {total} · 需关注 {c.attention} · 下载中 {c.downloading}

    今日 新计划 {a.today.plansCreated} · 提交下载 {a.today.downloadsSubmitted} · 入库文件 {a.today.filesImported} · 升级 {a.today.upgrades}

    ); } // ─── 计划带:下一次运行的 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 (
    启用 {s.enabled} / {s.total} {s.failed ? ( <> {" · "} 上次失败 {s.failed} ) : null} {board.data && !board.data.schedulerOnline ? · 调度器未在线 : null} ) : null } > {board.isPending ? ( ) : board.isError ? ( board.refetch()} /> ) : next.length === 0 ? ( ) : (
      {next.map((j) => { const last = j.lastStatus ? RUN_STATUS[j.lastStatus] : null; return (
    • {j.name} update.mutate( { id: j.id, body: { enabled: v } }, { onSuccess: () => toast.success(v ? `已启用${j.name}` : `已停用${j.name}`), onError: (e) => toast.error(errorMessage(e)), }, ) } />

      {j.runningSince ? "运行中" : j.nextRunAt ? countdown(j.nextRunAt, now) : "—"} {last ? {last.label} : null}

    • ); })}
    )}
    ); } // ─── 最近完成 / 失败 ─── 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 (
    {loading ? ( ) : err ? ( { void done.refetch(); void failed.refetch(); }} /> ) : rows.length === 0 ? ( ) : (
      {rows.map((r) => { const s = runStatus(r.status); return ( {runTitle(r)} {workflowLabel(r.workflowName)} · {formatRelative(r.finishedAt)} {s.label} ); })}
    )}
    ); } // ─── 来源健康:站点(含账号状态)+ Feed / 下载器 / 媒体服务器,有问题的排前 ─── const ACCOUNT: Record = { 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 (
    {rows.length === 0 ? ( ) : ( <> {accountIssues ?

    {accountIssues} 个站点的账号需要处理

    : null}
      {rows.slice(0, 7).map((r) => { const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled"); return ( {r.kind} {r.name} {r.account ? ( {r.account.label} ) : null} {h.label} ); })}
    )}
    ); } // ─── 运行状态摘要(/system/health) ─── const LEVEL: Record = { 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 (
    {lv.label} : null} > {h.isPending ? ( ) : h.isError ? ( h.refetch()} /> ) : d ? (
    进程 {alive} 在线 {stale.length ? ( {" "} · {stale.length} 个无心跳({stale.map((p) => `${p.processKind} ${p.pid}`).join("、")}) ) : null} 待执行 {d.engine.pending} {d.engine.oldestPendingAgeSec ? ` · 最久 ${formatDuration(d.engine.oldestPendingAgeSec)}` : ""} 24 小时失败率{" "} {d.engine.failureRate24h === null ? "—" : `${(d.engine.failureRate24h * 100).toFixed(1)}%`} 数据库{" "} {d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒`} {mountsBad ? {mountsBad} 个挂载根不可用 : null}
    ) : null}
    ); } function CacheSection({ c }: { c: Dashboard["cache"] }) { const volumeLabel = useVolumeLabel(); return (
    {c.volumes.length === 0 ? ( ) : (
      {c.volumes.map((v) => { const pct = v.usedPercent; const over = pct !== null && pct >= v.hardThresholdPercent; const near = pct !== null && pct >= v.resumePercent; return (
    • {volumeLabel(v)} {pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}

      阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "} {formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)} {v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}

      {v.notes.map((n) => (

      {n}

      ))}
    • ); })}
    )} 调整阈值与保留天数
    ); } function LiveIndicator() { const s = useSseStatus(); const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral"; const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中"; return {label}; } export default function Home() { const q = useLiveDashboard(); useLiveTasks(); return ( <> {q.data ? ( 更新于 {formatRelative(q.data.generatedAt)} ) : null} } /> {q.isPending ? (
    {["a", "b", "c", "d", "e", "f"].map((k) => (
    ))}
    ) : q.isError ? ( q.refetch()} /> ) : ( <>
    )} ); }