feat(db): 控制面 PG 定时备份、迁移前快照与原子恢复
This commit is contained in:
31 files changed
+3217
-17
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "维护与备份"
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user