feat(web): storage section on status page (FIX-5)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2c9e5a1d00
commit
6aaf8c0704
4 files changed
+441
-5
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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 内存:
|
||||
|
||||
Reference in new issue
Block a user