feat(db): 控制面 PG 定时备份、迁移前快照与原子恢复

This commit is contained in:
Lemon-miaow committed 2026-09-24 15:19:42 +08:00
1 parent abfe60d62d
commit c7db7d4126
31 files changed
+3217 -17

No files matched your search

+23
View File
@@ -721,6 +721,29 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
async function handleSession(ctx: SessionContext): Promise<boolean> {
switch (route(ctx)) {
case "GET platform/db-backup": {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
// Yesterday's daily run: fresh, so the card shows its healthy state.
const at = new Date(Date.now() - 9 * 3600 * 1000);
const stamp = at.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
sendJSON(ctx.res, 200, {
last: {
at: at.toISOString(),
name: `felis-db-${stamp}-daily.tar`,
label: "daily",
size_bytes: 3_482_112,
felis_version: "dev",
schema_version: 31,
dir: "/var/lib/felis/db-backups",
},
stale: false,
max_age_seconds: 26 * 3600,
});
return true;
}
case "GET updates/window":
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
+26 -6
View File
@@ -69,8 +69,8 @@
"reviewer": "Reviewer",
"reviewed_at": "Reviewed At",
"reject_reason": "Rejection Reason",
"updates_title": "Maintenance Window",
"updates_subtitle": "Configure the platform-wide maintenance window. A Scheduled auto-update component may only be applied by Felis within this window; outside it, updates are notify-only.",
"updates_title": "Maintenance & Backups",
"updates_subtitle": "Check that the control-plane database backup is fresh, and configure the platform-wide maintenance window. Felis may apply a Scheduled update only inside the window; outside it, updates are notify-only.",
"updates_current_unset": "No maintenance window set. Scheduled updates will degrade to notify-only and will not be applied automatically.",
"updates_start_label": "Start Time",
"updates_end_label": "End Time",
@@ -90,13 +90,33 @@
"updates_stat_start": "Start Time",
"updates_stat_end": "End Time",
"updates_stat_timezone": "Local Timezone",
"dbbackup_title": "Control-plane database backup",
"dbbackup_subtitle": "Users, passkeys, server ownership, quotas, audit logs and the world-archive index live in this database. felis-db-backup.timer on the host backs it up daily, and every upgrade snapshots it before migrating.",
"dbbackup_status_ok": "Healthy",
"dbbackup_status_stale": "Overdue",
"dbbackup_status_never": "Never backed up",
"dbbackup_refresh": "Refresh",
"dbbackup_loading": "Reading backup status…",
"dbbackup_field_when": "Last backup",
"dbbackup_field_label": "Kind",
"dbbackup_field_size": "Size",
"dbbackup_field_schema": "Schema version",
"dbbackup_field_file": "Bundle (host path)",
"dbbackup_label_daily": "Daily",
"dbbackup_label_pre_migrate": "Pre-upgrade snapshot",
"dbbackup_label_pre_restore": "Pre-restore snapshot",
"dbbackup_label_manual": "Manual",
"dbbackup_never_title": "No database backup has been recorded yet",
"dbbackup_stale_title": "The newest backup is more than {{hours}} hours old",
"dbbackup_fix_hint": "If the host failed now, accounts, server ownership and the archive index could not be recovered. Take a backup on the host now, then read the timer's log to find out why it did not run:",
"dbbackup_copy": "Copy command",
"dbbackup_offsite_note": "Backups are kept on this host only and are lost with its disk. Copy the backup directory to another machine regularly; restore and disaster-recovery steps are in the troubleshooting guide, §16.",
"build_import_submission_label": "Import parameters from submission",
"build_import_submission_placeholder": "Select a user submission...",
"build_import_submission_none": "No matching submissions found or not loaded",
"build_import_submission_hint": "Selecting a submission automatically populates the Image Reference, Context Reference, and the corresponding Dockerfile audit header.",
"build_import_submission_warning_title": "Warning: This submission is currently \"{{status}}\"",
"build_import_submission_warning_desc": "Manually triggering a build will not automatically mark this submission as approved, nor will it update its associated build status in the database. Use for emergency debugging or testing only.",
"_users_comment": "User administration (admin-tier only).",
"users_title": "Users",
"users_subtitle": "Manage platform user accounts, quotas, and sessions.",
@@ -112,9 +132,9 @@
"users_filter_status_all": "All Status",
"users_status_active": "Active",
"users_status_disabled": "Disabled",
"users_role_admin": "Admin",
"users_role_owner": "Owner",
"users_role_user": "User",
"users_role_admin": "Admin",
"users_role_owner": "Owner",
"users_role_user": "User",
"users_col_user": "User",
"users_col_role": "Role",
"users_col_servers": "Servers",
@@ -9,5 +9,5 @@
"admin_images": "Images",
"admin_builds": "Build Pipeline",
"admin_submissions": "Submissions",
"admin_updates": "Maintenance Window"
"admin_updates": "Maintenance & Backups"
}
+25 -5
View File
@@ -69,8 +69,8 @@
"reviewer": "审核人",
"reviewed_at": "审核时间",
"reject_reason": "驳回理由",
"updates_title": "维护窗口",
"updates_subtitle": "配置全局系统维护窗口。在此窗口内,Felis 可以自动应用系统更新;在窗口外,更新将降级为仅通知,不会自动执行。",
"updates_title": "维护与备份",
"updates_subtitle": "查看控制面数据库备份是否新鲜,并配置全局维护窗口。在窗口内 Felis 可以自动应用系统更新;在窗口外,更新降级为仅通知。",
"updates_current_unset": "当前未设置维护窗口。自动更新将降级为仅通知,不会自动执行。",
"updates_start_label": "开始时间",
"updates_end_label": "结束时间",
@@ -90,13 +90,33 @@
"updates_stat_start": "维护开始时间",
"updates_stat_end": "维护结束时间",
"updates_stat_timezone": "本地时区",
"dbbackup_title": "控制面数据库备份",
"dbbackup_subtitle": "用户、Passkey、服务器归属、配额、审计日志和世界存档索引都在这个数据库里。主机上的 felis-db-backup.timer 每天备份一次,每次升级迁移前也会先快照。",
"dbbackup_status_ok": "正常",
"dbbackup_status_stale": "已过期",
"dbbackup_status_never": "从未备份",
"dbbackup_refresh": "刷新",
"dbbackup_loading": "正在读取备份状态…",
"dbbackup_field_when": "最近一次备份",
"dbbackup_field_label": "类型",
"dbbackup_field_size": "大小",
"dbbackup_field_schema": "数据库版本",
"dbbackup_field_file": "备份文件(主机路径)",
"dbbackup_label_daily": "每日定时",
"dbbackup_label_pre_migrate": "升级前快照",
"dbbackup_label_pre_restore": "恢复前快照",
"dbbackup_label_manual": "手动",
"dbbackup_never_title": "还没有记录到任何数据库备份",
"dbbackup_stale_title": "最近一次备份已超过 {{hours}} 小时",
"dbbackup_fix_hint": "此时主机出故障,账号、服务器归属和存档索引都无法恢复。在主机上立即备份一次,再查看定时任务日志找出它没有运行的原因:",
"dbbackup_copy": "复制命令",
"dbbackup_offsite_note": "备份只保存在这台主机上,硬盘损坏或主机丢失时会一起丢失。请定期把备份目录复制到另一台机器;恢复与灾备步骤见故障排查文档 §16。",
"build_import_submission_label": "从已有的审核提交导入参数",
"build_import_submission_placeholder": "选择一个用户提交...",
"build_import_submission_none": "无匹配的提交或暂未加载",
"build_import_submission_hint": "选择提交将自动填充镜像引用、构建上下文引用和对应的 Dockerfile 审计头。",
"build_import_submission_warning_title": "警告:该提交状态为「{{status}}」",
"build_import_submission_warning_desc": "手动触发构建不会自动将该提交标记为已同意,也不会更新其关联的构建状态。仅适用于紧急调试或测试。",
"_users_comment": "用户管理(仅管理员可见)。",
"users_title": "用户管理",
"users_subtitle": "管理平台用户账号、配额和会话。",
@@ -113,8 +133,8 @@
"users_status_active": "正常",
"users_status_disabled": "已禁用",
"users_role_admin": "管理员",
"users_role_owner": "所有者",
"users_role_user": "普通用户",
"users_role_owner": "所有者",
"users_role_user": "普通用户",
"users_col_user": "用户",
"users_col_role": "角色",
"users_col_servers": "服务器数",
@@ -9,5 +9,5 @@
"admin_images": "镜像",
"admin_builds": "构建流水线",
"admin_submissions": "审核提交",
"admin_updates": "维护窗口"
"admin_updates": "维护与备份"
}
+13
View File
@@ -580,6 +580,19 @@ describe("image whitelist and builds wire shapes", () => {
});
});
describe("control-plane database backup", () => {
it("getDBBackup GETs /platform/db-backup and keeps a null last", async () => {
const status = { last: null, stale: true, max_age_seconds: 93600 };
const fetchSpy = fakeFetch(status);
vi.stubGlobal("fetch", fetchSpy);
const res = await api.getDBBackup();
expect(res).toEqual(status);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/platform/db-backup");
expect((opts as RequestInit).method).toBe("GET");
});
});
describe("backup now and server jobs wire shapes", () => {
it("backupNow POSTs to /servers/{name}/backup with no body and parses the 202", async () => {
const fetchSpy = fakeFetch({ name: "survival", status: "backing_up" }, { status: 202 });
+4
View File
@@ -27,6 +27,7 @@ import type {
WhitelistResult,
Submission,
UpdateWindow,
DBBackupStatus,
} from "./types";
import { loadConfig } from "./config";
import i18next from "i18next";
@@ -549,6 +550,9 @@ export const api = {
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
// Freshness of the host's control-plane database backup (felis-db-backup.timer).
getDBBackup: () => request<DBBackupStatus>("GET", "/platform/db-backup"),
// ---- User admin (admin-tier, spec §7 user admin) ----
listUsers: (params?: {
+22
View File
@@ -289,6 +289,28 @@ export interface UpdateWindow {
end: string | null;
}
// ---- Control-plane database backup (internal/api/handlers_dbbackup.go dbBackupView) ----
export type DBBackupLabel = "daily" | "pre-migrate" | "pre-restore" | "manual";
export interface DBBackupRecord {
at: string;
name: string;
label: DBBackupLabel;
size_bytes: number;
felis_version?: string;
schema_version?: number;
dir: string;
}
export interface DBBackupStatus {
/** Null until the host has recorded its first backup. */
last: DBBackupRecord | null;
/** True when there is no record or it is older than max_age_seconds. */
stale: boolean;
max_age_seconds: number;
}
// ---- User admin types (internal/api/repo.go UserView, UserDetail, QuotaView, SessionView) ----
export interface UserView {
+216
View File
@@ -0,0 +1,216 @@
import { useState } from "react";
import { AlertTriangle, Check, CheckCircle2, Copy, Database, Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { formatAbsolute, formatBytes, formatRelative } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { DBBackupLabel } from "@/lib/types";
// The control-plane database backup is taken on the host (felis-db-backup.timer),
// never through the API, so the card is read-only: it says how fresh the newest
// backup is and, when it is not, hands the admin the exact host commands.
const LABEL_KEY: Record<DBBackupLabel, string> = {
daily: "dbbackup_label_daily",
"pre-migrate": "dbbackup_label_pre_migrate",
"pre-restore": "dbbackup_label_pre_restore",
manual: "dbbackup_label_manual",
};
const FIX_COMMANDS = ["sudo felis db backup", "journalctl -u felis-db-backup -n 50 --no-pager"];
function CopyCommand({ command }: { command: string }) {
const { t } = useTranslation("admin");
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard denied (non-secure context): the command stays selectable.
}
}
return (
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/40 pl-3 pr-1 py-1">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap py-1 font-mono text-xs text-foreground" title={command}>
{command}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={copy}
aria-label={t("dbbackup_copy")}
title={t("dbbackup_copy")}
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</div>
);
}
function Field({ label, children, title }: { label: string; children: React.ReactNode; title?: string }) {
return (
<div className="min-w-0 space-y-1">
<dt className="text-[11px] font-medium text-muted-foreground">{label}</dt>
<dd className="truncate text-sm font-semibold text-foreground" title={title}>
{children}
</dd>
</div>
);
}
export function DBBackupCard() {
const { t, i18n } = useTranslation("admin");
const locale = i18n.language;
const { data, error, loading, reload } = useAsync(() => api.getDBBackup(), []);
const last = data?.last ?? null;
const maxAgeHours = data ? Math.round(data.max_age_seconds / 3600) : 26;
const state: "loading" | "error" | "never" | "stale" | "ok" = !data
? error
? "error"
: "loading"
: !last
? "never"
: data.stale
? "stale"
: "ok";
const badge = (() => {
switch (state) {
case "ok":
return (
<Badge className="gap-1 border-transparent bg-emerald-500/15 text-emerald-500">
<CheckCircle2 className="h-3 w-3" />
{t("dbbackup_status_ok")}
</Badge>
);
case "stale":
return (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{t("dbbackup_status_stale")}
</Badge>
);
case "never":
return (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{t("dbbackup_status_never")}
</Badge>
);
default:
return null;
}
})();
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
"hidden rounded-md p-2 sm:block",
state === "stale" || state === "never"
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
<Database className="h-5 w-5" />
</div>
<div className="min-w-0">
<CardTitle className="flex flex-wrap items-center gap-2 text-base font-semibold">
{t("dbbackup_title")}
{badge}
</CardTitle>
<p className="mt-0.5 text-xs text-muted-foreground">{t("dbbackup_subtitle")}</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={reload}
disabled={loading}
aria-label={t("dbbackup_refresh")}
title={t("dbbackup_refresh")}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{state === "loading" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("dbbackup_loading")}
</div>
)}
{state === "error" && <MessageLine kind="error" message={humanizeError(error)} />}
{last && (
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 lg:grid-cols-4">
<Field label={t("dbbackup_field_when")} title={formatAbsolute(last.at, locale)}>
<span className={cn(state === "stale" && "text-destructive")}>
{formatRelative(last.at, Date.now(), locale) || "—"}
</span>
<span className="block truncate text-[11px] font-normal text-muted-foreground">
{formatAbsolute(last.at, locale)}
</span>
</Field>
<Field label={t("dbbackup_field_label")}>
{LABEL_KEY[last.label] ? t(LABEL_KEY[last.label]) : last.label}
</Field>
<Field label={t("dbbackup_field_size")}>
<span className="font-mono">{formatBytes(last.size_bytes)}</span>
</Field>
<Field label={t("dbbackup_field_schema")}>
<span className="font-mono">{last.schema_version ? `#${last.schema_version}` : "—"}</span>
</Field>
<div className="col-span-2 min-w-0 space-y-1 lg:col-span-4">
<dt className="text-[11px] font-medium text-muted-foreground">{t("dbbackup_field_file")}</dt>
<dd className="break-all font-mono text-xs text-foreground">
{last.dir.replace(/\/+$/, "")}/{last.name}
</dd>
</div>
</dl>
)}
{(state === "stale" || state === "never") && (
<div className="space-y-3 rounded-lg border border-destructive/25 bg-destructive/5 p-4">
<div className="flex items-start gap-2 text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="font-semibold">
{state === "never" ? t("dbbackup_never_title") : t("dbbackup_stale_title", { hours: maxAgeHours })}
</p>
<p className="text-xs leading-relaxed text-destructive/90">{t("dbbackup_fix_hint")}</p>
</div>
</div>
<div className="space-y-2">
{FIX_COMMANDS.map((c) => (
<CopyCommand key={c} command={c} />
))}
</div>
</div>
)}
{data && (
<p className="rounded-md border border-border/40 bg-muted/15 p-3 text-[11px] leading-relaxed text-muted-foreground">
{t("dbbackup_offsite_note")}
</p>
)}
</CardContent>
</Card>
);
}
+4
View File
@@ -13,6 +13,7 @@ import { Loading, ErrorState } from "@/components/States";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { formatAbsolute } from "@/lib/format";
import { DBBackupCard } from "./DBBackupCard";
function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string {
if (!dateOrStr) return "";
@@ -166,6 +167,9 @@ export function UpdatesPage() {
<div className="space-y-6">
<PageHeader icon={Clock} title={t("updates_title")} subtitle={t("updates_subtitle")} />
{/* Control-plane database backup freshness (read-only, host timer) */}
<DBBackupCard />
{/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<StatCard