diff --git a/apps/web/app/api/system.ts b/apps/web/app/api/system.ts index d444a40f..947801c5 100644 --- a/apps/web/app/api/system.ts +++ b/apps/web/app/api/system.ts @@ -1,9 +1,16 @@ -import { useQuery } from "@tanstack/react-query"; -import type { SystemHealth, SystemHealthHistory } from "@umt/contracts"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + StorageCleanupResult, + StorageCleanupTarget, + SystemHealth, + SystemHealthHistory, + SystemStorage, +} from "@umt/contracts"; import { apiData } from "~/lib/api.ts"; import { qk } from "~/lib/query-keys.ts"; -// 进程与健康(W9-I):GET /system/health、/system/health/history,仅 admin。 +// 进程与健康(W9-I):GET /system/health、/system/health/history;存储(CACHE-2):GET /system/storage、 +// POST /system/storage/cleanup/{target}。仅 admin。 export function useSystemHealth(refetchMs = 15_000) { return useQuery({ @@ -20,3 +27,26 @@ export function useSystemHealthHistory(hours: number) { refetchInterval: 60_000, }); } + +/** 存储明细:目录用量服务端缓存 5 分钟,这里 1 分钟刷新一次 */ +export function useSystemStorage() { + return useQuery({ + queryKey: qk.sub("system", "storage"), + queryFn: ({ signal }) => apiData("/system/storage", { signal }), + refetchInterval: 60_000, + }); +} + +/** 手动清理:把对应的维护任务排入立即执行 */ +export function useStorageCleanup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (target: StorageCleanupTarget) => + apiData("/system/storage/cleanup/{target}", { + method: "POST", + pathParams: { target }, + body: {}, + }), + onSuccess: () => void qc.invalidateQueries({ queryKey: qk.sub("system", "storage") }), + }); +} diff --git a/apps/web/app/components/status/storage-section.tsx b/apps/web/app/components/status/storage-section.tsx new file mode 100644 index 00000000..b208bcb0 --- /dev/null +++ b/apps/web/app/components/status/storage-section.tsx @@ -0,0 +1,395 @@ +import type { + HealthLevel, + StorageBudgetUsage, + StorageCleanupTarget, + SystemHealth, + SystemStorage, +} from "@umt/contracts"; +import { AlertTriangle, Eraser, HardDrive, LoaderCircle } from "lucide-react"; +import type { ReactNode } from "react"; +import { toast } from "sonner"; +import { useStorageCleanup, useSystemStorage } from "~/api/system.ts"; +import { DataTable } from "~/components/console/data-table.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { formatRelative } from "~/lib/labels.ts"; +import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; + +// 运行状态页「存储」节(FIX-5):GET /system/health 的存储告警 + GET /system/storage 明细(库体积与预算、表 top 10、 +// 缓存用量、图片 / 备份 / 会话与证据、DATA_DIR 分目录、各清理任务),手动清理调 POST /system/storage/cleanup/{target}。 +// 预算与保留期在「设置 → 自动化 → 存储与保留」改,见 docs/runbook/storage.md。 + +const LEVEL: Record = { + ok: { label: "正常", tone: "success" }, + warn: { label: "需要关注", tone: "warning" }, + critical: { label: "需要处理", tone: "destructive" }, + unknown: { label: "取不到数据", tone: "neutral" }, +}; + +/** 清理目标的界面名称 */ +export const CLEANUP_LABEL: Record = { + observation: "Feed 观察记录", + sse_event: "SSE 补读事件", + heartbeat: "进程心跳", + task_run: "任务运行", + agent_event: "Agent 事件", + audit: "审计事件", + capability_stats: "能力调用统计", + identity_pool: "待识别池归档", + analyze: "统计信息更新", + images: "图片缓存容量", + kv_cache: "过期缓存", +}; + +/** kv_cache region 的界面名称;未知的原样显示 */ +const REGION_LABEL: Record = { + "tmdb.request": "TMDB 请求", + "tmdb.recognize": "TMDB 识别结果", + "site.detail": "站点详情", + enrich: "元数据补全", + "web.search": "网页搜索", +}; + +const TOP_TABLES = 10; + +function BudgetBar({ label, u, extra }: { label: string; u: StorageBudgetUsage; extra?: string | null }) { + const pct = u.usedBytes !== null && u.budgetBytes ? Math.min(100, (u.usedBytes / u.budgetBytes) * 100) : null; + const warnPct = u.warnBytes !== null && u.budgetBytes ? (u.warnBytes / u.budgetBytes) * 100 : null; + return ( +
  • +
    + {label} + + {u.error ? `不可读 ${u.error}` : pct === null ? LEVEL[u.level].label : `${pct.toFixed(0)}%`} + +
    +
    +
    + {warnPct !== null ? ( + + ) : null} +
    +

    + {u.usedBytes === null ? "用量未知" : formatBytes(u.usedBytes, 1)} + {u.budgetBytes ? ` / 预算 ${formatBytes(u.budgetBytes, 0)}` : " · 无预算"} + {u.warnBytes && u.budgetBytes && u.warnBytes !== u.budgetBytes ? `,告警线 ${formatBytes(u.warnBytes, 0)}` : ""} + {extra ? ` · ${extra}` : ""} +

    +
  • + ); +} + +function Panel({ title, children, note }: { title: string; children: ReactNode; note?: ReactNode }) { + return ( +
    +
    +

    {title}

    + {note ? {note} : null} +
    + {children} +
    + ); +} + +type TableRow = SystemStorage["db"]["tables"][number]; +type KvRow = SystemStorage["kvCache"][number]; +type DirRow = SystemStorage["dataDir"][number]; +type CleanupRow = SystemStorage["cleanup"][number]; + +const STATUS_TEXT: Record = { + ok: { label: "成功", tone: "success" }, + failed: { label: "失败", tone: "destructive" }, + skipped: { label: "已跳过", tone: "neutral" }, + running: { label: "运行中", tone: "warning" }, +}; + +function CleanupTable({ rows }: { rows: CleanupRow[] }) { + const clean = useStorageCleanup(); + const run = (t: StorageCleanupTarget) => + clean.mutate(t, { + onSuccess: (r) => + r.queued ? toast.success(`已排入清理:${CLEANUP_LABEL[t]}`) : toast.info(`${CLEANUP_LABEL[t]}已在排队或运行中`), + onError: (e) => toast.error(errorMessage(e)), + }); + return ( + + rows={rows} + rowKey={(r) => r.target} + empty={{ title: "没有清理任务" }} + columns={[ + { + key: "target", + header: "清理项", + cell: (r) => ( +
    +

    {CLEANUP_LABEL[r.target] ?? r.target}

    +

    + {r.jobId} +

    +
    + ), + }, + { + key: "retention", + header: "保留", + hideBelow: "md", + cell: (r) => {r.retention ?? "—"}, + }, + { + key: "last", + header: "上次结果", + hideBelow: "sm", + cell: (r) => { + const s = r.lastStatus ? STATUS_TEXT[r.lastStatus] : null; + return ( +
    + + {r.enabled === false ? 已停用 : null} + {s ? {s.label} : 未运行} + {r.lastRunAt ? {formatRelative(r.lastRunAt)} : null} + + {r.lastSummary ? ( +

    + {r.lastSummary} +

    + ) : null} +
    + ); + }, + }, + { + key: "next", + header: "下次", + hideBelow: "lg", + cell: (r) => ( + + {r.nextRunAt ? formatRelative(r.nextRunAt) : "—"} + + ), + }, + { + key: "act", + header: 操作, + align: "right", + cell: (r) => ( + + ), + }, + ]} + /> + ); +} + +/** 运行状态页的「存储」节 */ +export function StorageSection({ health }: { health: SystemHealth }) { + const q = useSystemStorage(); + const s = q.data; + const hs = health.storage; + const level: HealthLevel = s?.level ?? hs.level; + return ( +
    +
    + +

    + 存储 +

    + {LEVEL[level].label} + {s ? ( + + 检查于 {formatRelative(s.checkedAt)} + {s.dirsScannedAt ? ` · 目录用量统计于 ${formatRelative(s.dirsScannedAt)}` : ""} + + ) : null} +
    + {hs.alerts.length ? ( +
      + {hs.alerts.map((a) => ( +
    • + + {a} +
    • + ))} +
    + ) : null} + {q.isPending ? ( + + ) : q.isError ? ( + q.refetch()} /> + ) : s ? ( +
    +
      + + + + +
    + +
    + + + rows={s.db.tables.slice(0, TOP_TABLES)} + rowKey={(t) => `${t.schema}.${t.name}`} + empty={{ title: "取不到表体积" }} + columns={[ + { + key: "name", + header: "表", + cell: (t) => ( + + {t.schema === "public" ? t.name : `${t.schema}.${t.name}`} + + ), + }, + { + key: "total", + header: "合计", + align: "right", + cell: (t) => {formatBytes(t.totalBytes, 1)}, + }, + { + key: "idx", + header: "索引", + align: "right", + hideBelow: "md", + cell: (t) => ( + + {formatBytes(t.indexBytes, 1)} + + ), + }, + { + key: "rows", + header: "估算行数", + align: "right", + hideBelow: "sm", + cell: (t) => ( + + {t.rowsEstimate === null ? "—" : t.rowsEstimate.toLocaleString("zh-CN")} + + ), + }, + ]} + /> + +
    + + + rows={s.kvCache} + rowKey={(r) => r.region} + empty={{ title: "缓存为空" }} + columns={[ + { + key: "region", + header: "类别", + cell: (r) => ( + + {REGION_LABEL[r.region] ?? {r.region}} + + ), + }, + { + key: "entries", + header: "条数", + align: "right", + cell: (r) => {r.entries.toLocaleString("zh-CN")}, + }, + { + key: "expired", + header: "已过期", + align: "right", + hideBelow: "sm", + cell: (r) => ( + {r.expired.toLocaleString("zh-CN")} + ), + }, + { + key: "bytes", + header: "体积", + align: "right", + cell: (r) => {formatBytes(r.bytes, 1)}, + }, + ]} + /> + + + + rows={s.dataDir} + rowKey={(d) => d.path} + empty={{ title: "还没有统计", description: "目录用量每 5 分钟统计一次。" }} + columns={[ + { + key: "name", + header: "目录", + cell: (d) => ( +
    +

    {d.name}

    +

    + {d.path} +

    + {d.error ?

    {d.error}

    : null} +
    + ), + }, + { + key: "files", + header: "文件", + align: "right", + hideBelow: "sm", + cell: (d) => ( + {d.files.toLocaleString("zh-CN")} + ), + }, + { + key: "used", + header: "占用", + align: "right", + cell: (d) => ( + {formatBytes(d.usedBytes, 1)} + ), + }, + ]} + /> +
    +
    +
    + + + + +
    + ) : null} +
    + ); +} diff --git a/apps/web/app/routes/console/status.tsx b/apps/web/app/routes/console/status.tsx index 245398d9..daf36dd6 100644 --- a/apps/web/app/routes/console/status.tsx +++ b/apps/web/app/routes/console/status.tsx @@ -22,6 +22,7 @@ import { KvList } from "~/components/console/kv-list.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { HistoryChart } from "~/components/status/history-chart.tsx"; +import { StorageSection } from "~/components/status/storage-section.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; import { formatDuration, formatRelative } from "~/lib/labels.ts"; @@ -30,7 +31,7 @@ import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; export const meta = () => [{ title: "运行状态 · UHD 媒体自动化控制台" }]; -// 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务。阈值见 docs/runbook/ops.md。 +// 运行状态(W9-I):进程心跳、事件循环、SSE、DB、磁盘、挂载根、引擎与定时任务;存储节(FIX-5)见 storage-section.tsx。阈值见 docs/runbook/ops.md。 const LEVEL: Record = { ok: { label: "正常", tone: "success" }, @@ -108,6 +109,7 @@ function problems(d: SystemHealth): string[] { if (mounts.length) out.push(`${mounts.length} 个挂载根不可用`); if (d.engine.level === "warn" || d.engine.level === "critical") out.push("任务积压或失败率偏高"); if (d.schedules.failing.length) out.push(`${d.schedules.failing.length} 个定时任务上次运行失败`); + if (d.storage.level === "warn" || d.storage.level === "critical") out.push("存储用量超过告警线或预算,见「存储」"); return out; } @@ -463,7 +465,7 @@ export default function StatusPage() { <> + ) : null} diff --git a/docs/runbook/storage.md b/docs/runbook/storage.md index 5b0ca3a7..d9a0e319 100644 --- a/docs/runbook/storage.md +++ b/docs/runbook/storage.md @@ -119,6 +119,14 @@ PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}} `{ level, alerts[] }`,level 计入总档位。库体积实时取;目录用量只用缓存值,过期时后台刷新,本次不等 (进程刚启动时目录项是 unknown,不参与汇总)。 +## 运行状态页「存储」节(FIX-5) + +控制台 → 运行状态最下方。`GET /system/health` 的 `storage.level / alerts` 显示为档位与告警条(横幅也会提示), +`GET /system/storage` 显示:数据库体积 / 图片缓存 / 备份 / 会话与证据对照预算的用量条(竖线是告警线)、体积前 10 的表、 +`kv_cache` 各类缓存的条数 / 已过期 / 体积、DATA_DIR 各子目录占用、各清理任务的保留期与上次结果。每行「立即清理」调 +`POST /system/storage/cleanup/{target}`(已在排队或运行中则合并),结果看计划页的运行记录。明细每分钟刷新,目录用量服务端缓存 5 分钟。 +代码:`apps/web/app/components/status/storage-section.tsx`。 + ## PG 参数 `docker-compose.yml` 的 postgres 服务按 4C / 8G 给 PG 3G 内存: