feat(web): storage section on status page (FIX-5)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 19:37:11 +08:00
1 parent 2c9e5a1d00
commit 6aaf8c0704
4 files changed
+441 -5

No files matched your search

+33 -3
View File
@@ -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<SystemStorage>("/system/storage", { signal }),
refetchInterval: 60_000,
});
}
/** 手动清理:把对应的维护任务排入立即执行 */
export function useStorageCleanup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (target: StorageCleanupTarget) =>
apiData<StorageCleanupResult>("/system/storage/cleanup/{target}", {
method: "POST",
pathParams: { target },
body: {},
}),
onSuccess: () => void qc.invalidateQueries({ queryKey: qk.sub("system", "storage") }),
});
}
@@ -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<HealthLevel, { label: string; tone: StatusTone }> = {
ok: { label: "正常", tone: "success" },
warn: { label: "需要关注", tone: "warning" },
critical: { label: "需要处理", tone: "destructive" },
unknown: { label: "取不到数据", tone: "neutral" },
};
/** 清理目标的界面名称 */
export const CLEANUP_LABEL: Record<StorageCleanupTarget, string> = {
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<string, string> = {
"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 (
<li className="min-w-0">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="min-w-0 truncate">{label}</span>
<StatusBadge tone={LEVEL[u.level].tone}>
{u.error ? `不可读 ${u.error}` : pct === null ? LEVEL[u.level].label : `${pct.toFixed(0)}%`}
</StatusBadge>
</div>
<div className="relative mt-1.5 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
u.level === "critical" ? "bg-destructive" : u.level === "warn" ? "bg-warning" : "bg-foreground/60",
)}
style={{ width: `${pct ?? 0}%` }}
/>
{warnPct !== null ? (
<span className="absolute inset-y-0 w-px bg-warning" style={{ left: `${warnPct}%` }} />
) : null}
</div>
<p className="mt-1 text-meta text-muted-foreground tabular-nums">
{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}` : ""}
</p>
</li>
);
}
function Panel({ title, children, note }: { title: string; children: ReactNode; note?: ReactNode }) {
return (
<section className="min-w-0">
<div className="mb-2 flex flex-wrap items-baseline gap-2">
<h3 className="text-caption font-semibold text-foreground">{title}</h3>
{note ? <span className="text-meta text-muted-foreground">{note}</span> : null}
</div>
{children}
</section>
);
}
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<string, { label: string; tone: StatusTone }> = {
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 (
<DataTable<CleanupRow>
rows={rows}
rowKey={(r) => r.target}
empty={{ title: "没有清理任务" }}
columns={[
{
key: "target",
header: "清理项",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{CLEANUP_LABEL[r.target] ?? r.target}</p>
<p className="truncate font-mono text-meta text-muted-foreground" title={r.jobId}>
{r.jobId}
</p>
</div>
),
},
{
key: "retention",
header: "保留",
hideBelow: "md",
cell: (r) => <span className="whitespace-nowrap text-muted-foreground">{r.retention ?? "—"}</span>,
},
{
key: "last",
header: "上次结果",
hideBelow: "sm",
cell: (r) => {
const s = r.lastStatus ? STATUS_TEXT[r.lastStatus] : null;
return (
<div className="min-w-0">
<span className="flex flex-wrap items-center gap-1.5 text-meta text-muted-foreground">
{r.enabled === false ? <StatusBadge tone="neutral">已停用</StatusBadge> : null}
{s ? <StatusBadge tone={s.tone}>{s.label}</StatusBadge> : <span>未运行</span>}
{r.lastRunAt ? <span title={formatDateTime(r.lastRunAt)}>{formatRelative(r.lastRunAt)}</span> : null}
</span>
{r.lastSummary ? (
<p className="mt-0.5 max-w-80 truncate text-meta text-muted-foreground" title={r.lastSummary}>
{r.lastSummary}
</p>
) : null}
</div>
);
},
},
{
key: "next",
header: "下次",
hideBelow: "lg",
cell: (r) => (
<span className="whitespace-nowrap text-muted-foreground">
{r.nextRunAt ? formatRelative(r.nextRunAt) : "—"}
</span>
),
},
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) => (
<Button
variant="outline"
size="xs"
disabled={r.enabled === null || (clean.isPending && clean.variables === r.target)}
onClick={() => run(r.target)}
aria-label={`立即清理${CLEANUP_LABEL[r.target] ?? r.target}`}
>
{clean.isPending && clean.variables === r.target ? <LoaderCircle className="animate-spin" /> : <Eraser />}
立即清理
</Button>
),
},
]}
/>
);
}
/** 运行状态页的「存储」节 */
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 (
<section aria-labelledby="storage-title" className="pg-slab min-w-0 rounded-xl p-5">
<div className="mb-3 flex flex-wrap items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<h2 id="storage-title" className="text-body font-semibold text-foreground">
存储
</h2>
<StatusBadge tone={LEVEL[level].tone}>{LEVEL[level].label}</StatusBadge>
{s ? (
<span className="ml-auto text-meta text-muted-foreground">
检查于 {formatRelative(s.checkedAt)}
{s.dirsScannedAt ? ` · 目录用量统计于 ${formatRelative(s.dirsScannedAt)}` : ""}
</span>
) : null}
</div>
{hs.alerts.length ? (
<ul
role="status"
className={cn(
"mb-4 space-y-0.5 rounded-lg border px-3 py-2 text-caption",
hs.level === "critical" ? "border-destructive/30 bg-destructive/10" : "border-warning/30 bg-warning/10",
)}
>
{hs.alerts.map((a) => (
<li key={a} className="flex items-start gap-2">
<AlertTriangle
className={cn(
"mt-0.5 h-3.5 w-3.5 shrink-0",
hs.level === "critical" ? "text-destructive" : "text-warning",
)}
/>
<span className="min-w-0 break-words">{a}</span>
</li>
))}
</ul>
) : null}
{q.isPending ? (
<LoadingState rows={4} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : s ? (
<div className="space-y-6">
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<BudgetBar label="数据库体积" u={s.db} />
<BudgetBar
label="图片缓存"
u={s.images}
extra={s.images.files !== null ? `${s.images.files} 个文件` : null}
/>
<BudgetBar label="备份" u={s.backups} extra={s.backups.count !== null ? `${s.backups.count} 份` : null} />
<BudgetBar label="会话与证据" u={s.sessionsEvidence} />
</ul>
<div className="grid gap-6 xl:grid-cols-2">
<Panel title={`体积最大的表(前 ${TOP_TABLES})`} note="含索引与 TOAST">
<DataTable<TableRow>
rows={s.db.tables.slice(0, TOP_TABLES)}
rowKey={(t) => `${t.schema}.${t.name}`}
empty={{ title: "取不到表体积" }}
columns={[
{
key: "name",
header: "表",
cell: (t) => (
<span className="font-mono text-caption break-all">
{t.schema === "public" ? t.name : `${t.schema}.${t.name}`}
</span>
),
},
{
key: "total",
header: "合计",
align: "right",
cell: (t) => <span className="whitespace-nowrap tabular-nums">{formatBytes(t.totalBytes, 1)}</span>,
},
{
key: "idx",
header: "索引",
align: "right",
hideBelow: "md",
cell: (t) => (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{formatBytes(t.indexBytes, 1)}
</span>
),
},
{
key: "rows",
header: "估算行数",
align: "right",
hideBelow: "sm",
cell: (t) => (
<span className="tabular-nums text-muted-foreground">
{t.rowsEstimate === null ? "—" : t.rowsEstimate.toLocaleString("zh-CN")}
</span>
),
},
]}
/>
</Panel>
<div className="space-y-6">
<Panel title="缓存" note="条数含尚未清理的过期条目">
<DataTable<KvRow>
rows={s.kvCache}
rowKey={(r) => r.region}
empty={{ title: "缓存为空" }}
columns={[
{
key: "region",
header: "类别",
cell: (r) => (
<span title={r.region}>
{REGION_LABEL[r.region] ?? <span className="font-mono text-caption">{r.region}</span>}
</span>
),
},
{
key: "entries",
header: "条数",
align: "right",
cell: (r) => <span className="tabular-nums">{r.entries.toLocaleString("zh-CN")}</span>,
},
{
key: "expired",
header: "已过期",
align: "right",
hideBelow: "sm",
cell: (r) => (
<span className="tabular-nums text-muted-foreground">{r.expired.toLocaleString("zh-CN")}</span>
),
},
{
key: "bytes",
header: "体积",
align: "right",
cell: (r) => <span className="whitespace-nowrap tabular-nums">{formatBytes(r.bytes, 1)}</span>,
},
]}
/>
</Panel>
<Panel title="数据目录" note="DATA_DIR 各子目录">
<DataTable<DirRow>
rows={s.dataDir}
rowKey={(d) => d.path}
empty={{ title: "还没有统计", description: "目录用量每 5 分钟统计一次。" }}
columns={[
{
key: "name",
header: "目录",
cell: (d) => (
<div className="min-w-0">
<p className="font-medium">{d.name}</p>
<p className="truncate font-mono text-meta text-muted-foreground" title={d.path}>
{d.path}
</p>
{d.error ? <p className="text-meta text-destructive">{d.error}</p> : null}
</div>
),
},
{
key: "files",
header: "文件",
align: "right",
hideBelow: "sm",
cell: (d) => (
<span className="tabular-nums text-muted-foreground">{d.files.toLocaleString("zh-CN")}</span>
),
},
{
key: "used",
header: "占用",
align: "right",
cell: (d) => (
<span className="whitespace-nowrap tabular-nums">{formatBytes(d.usedBytes, 1)}</span>
),
},
]}
/>
</Panel>
</div>
</div>
<Panel title="清理任务" note="立即清理会把对应的维护任务排入执行,结果看运行记录">
<CleanupTable rows={s.cleanup} />
</Panel>
</div>
) : null}
</section>
);
}
+5 -2
View File
@@ -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<HealthLevel, { label: string; tone: StatusTone }> = {
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() {
<>
<PageHeader
title="运行状态"
description="进程心跳、数据库、磁盘与挂载根。15 秒刷新一次。"
description="进程心跳、数据库、磁盘、挂载根与存储。15 秒刷新一次。"
actions={
<Button
variant="outline"
@@ -575,6 +577,7 @@ export default function StatusPage() {
<Disks d={d} />
<Mounts d={d} />
</div>
<StorageSection health={d} />
</div>
) : null}
</>
+8
View File
@@ -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 内存: