fix(dbbackup): 缺 MinecraftServer 导出时重试、CLI 失败退出、面板与 watchdog 告警,异地快照要求完整

This commit is contained in:
Lemon-miaow committed 2026-09-27 09:38:08 +08:00
1 parent 983727d9c0
commit c1cdef0d6a
21 files changed
+605 -38

No files matched your search

@@ -116,6 +116,7 @@
"dbbackup_status_ok": "Healthy",
"dbbackup_status_stale": "Overdue",
"dbbackup_status_never": "Never backed up",
"dbbackup_status_incomplete": "Incomplete",
"dbbackup_refresh": "Refresh",
"dbbackup_loading": "Reading backup status…",
"dbbackup_field_when": "Last backup",
@@ -126,9 +127,12 @@
"dbbackup_label_daily": "Daily",
"dbbackup_label_pre_migrate": "Pre-upgrade snapshot",
"dbbackup_label_pre_restore": "Pre-restore snapshot",
"dbbackup_label_offsite": "Off-site copy 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_servers_title": "The newest backup lacks the server definitions",
"dbbackup_servers_hint": "The cluster did not answer when the backup was taken. Restoring from it brings back accounts and the database, but no servers. Once the cluster answers, take a backup on the host again:",
"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.",
@@ -116,6 +116,7 @@
"dbbackup_status_ok": "正常",
"dbbackup_status_stale": "已过期",
"dbbackup_status_never": "从未备份",
"dbbackup_status_incomplete": "不完整",
"dbbackup_refresh": "刷新",
"dbbackup_loading": "正在读取备份状态…",
"dbbackup_field_when": "最近一次备份",
@@ -126,9 +127,12 @@
"dbbackup_label_daily": "每日定时",
"dbbackup_label_pre_migrate": "升级前快照",
"dbbackup_label_pre_restore": "恢复前快照",
"dbbackup_label_offsite": "异地复制快照",
"dbbackup_label_manual": "手动",
"dbbackup_never_title": "还没有记录到任何数据库备份",
"dbbackup_stale_title": "最近一次备份已超过 {{hours}} 小时",
"dbbackup_servers_title": "最近一次备份缺少服务器定义",
"dbbackup_servers_hint": "备份时集群没有响应。用它恢复能找回账号和数据库,但一台服务器都不会有。等集群恢复响应后,在主机上重新备份一次:",
"dbbackup_fix_hint": "此时主机出故障,账号、服务器归属和存档索引都无法恢复。在主机上立即备份一次,再查看定时任务日志找出它没有运行的原因:",
"dbbackup_copy": "复制命令",
"dbbackup_offsite_note": "备份只保存在这台主机上,硬盘损坏或主机丢失时会一起丢失。请定期把备份目录复制到另一台机器;恢复与灾备步骤见故障排查文档 §16。",
+7 -2
View File
@@ -2329,8 +2329,11 @@ export interface components {
at: string;
/** @description Bundle file name, felis-db-<UTC stamp>-<label>.tar. */
name: string;
/** @enum {string} */
label: "daily" | "pre-migrate" | "pre-restore" | "manual";
/**
* @description offsite is the bundle `felis offsite sync` takes after copying world archives; a restore records the newest bundle on disk, which may be one.
* @enum {string}
*/
label: "daily" | "pre-migrate" | "pre-restore" | "offsite" | "manual";
/** Format: int64 */
size_bytes: number;
felis_version?: string;
@@ -2338,6 +2341,8 @@ export interface components {
schema_version?: number;
/** @description Backup directory on the host. */
dir: string;
/** @description Why the bundle lacks the MinecraftServer objects (the cluster did not answer the export), when it does. A restore from it brings back the database but no servers. */
servers_error?: string;
} | null;
/** @description True when there is no record or it is older than max_age_seconds. */
stale: boolean;
+3 -1
View File
@@ -457,7 +457,7 @@ export interface UpdateWindow {
// ---- Control-plane database backup (internal/api/handlers_dbbackup.go dbBackupView) ----
export type DBBackupLabel = "daily" | "pre-migrate" | "pre-restore" | "manual";
export type DBBackupLabel = "daily" | "pre-migrate" | "pre-restore" | "offsite" | "manual";
export interface DBBackupRecord {
at: string;
@@ -467,6 +467,8 @@ export interface DBBackupRecord {
felis_version?: string;
schema_version?: number;
dir: string;
/** Why the bundle lacks the MinecraftServer objects, when it does: a restore from it brings back no servers. */
servers_error?: string;
}
export interface DBBackupStatus {
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import i18next from "i18next";
import { DBBackupCard } from "./DBBackupCard";
import type { DBBackupStatus } from "@/lib/types";
const calls = vi.hoisted(() => ({ getDBBackup: vi.fn() }));
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
const AT = new Date(Date.now() - 3 * 3600 * 1000).toISOString();
const REASON = "k3s kubectl get minecraftservers: exit status 1: connection refused (tried 3 times)";
function status(last: Partial<NonNullable<DBBackupStatus["last"]>>, stale = false): DBBackupStatus {
return {
last: {
at: AT,
name: "felis-db-20260926T033000Z-daily.tar",
label: "daily",
size_bytes: 4 << 20,
schema_version: 42,
dir: "/var/lib/felis/db-backups",
...last,
},
stale,
max_age_seconds: 93600,
};
}
beforeEach(() => {
calls.getDBBackup.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
return i18next.changeLanguage("en-US");
});
describe("DBBackupCard", () => {
it("shows a whole, fresh backup as healthy", async () => {
calls.getDBBackup.mockResolvedValue(status({}));
render(<DBBackupCard />);
expect(await screen.findByText("Healthy")).toBeTruthy();
expect(screen.queryByText("Incomplete")).toBeNull();
expect(screen.queryByText("The newest backup lacks the server definitions")).toBeNull();
});
it("warns that a fresh backup without the MinecraftServer objects restores no servers", async () => {
calls.getDBBackup.mockResolvedValue(status({ servers_error: REASON }));
render(<DBBackupCard />);
expect(await screen.findByText("Incomplete")).toBeTruthy();
expect(screen.queryByText("Healthy")).toBeNull();
expect(screen.getByText("The newest backup lacks the server definitions")).toBeTruthy();
expect(screen.getByText(/Restoring from it brings back accounts and the database, but no servers/)).toBeTruthy();
expect(screen.getByText(REASON)).toBeTruthy();
expect(screen.getByTitle("sudo k3s kubectl get minecraftservers -A")).toBeTruthy();
expect(screen.getByTitle("sudo felis db backup")).toBeTruthy();
});
it("keeps the overdue alarm first and still names the missing servers", async () => {
calls.getDBBackup.mockResolvedValue(status({ servers_error: REASON }, true));
render(<DBBackupCard />);
expect(await screen.findByText("Overdue")).toBeTruthy();
expect(screen.queryByText("Incomplete")).toBeNull();
expect(screen.getByText("The newest backup lacks the server definitions")).toBeTruthy();
});
it("names an off-site copy snapshot by its kind", async () => {
calls.getDBBackup.mockResolvedValue(status({ label: "offsite", name: "felis-db-20260926T101500Z-offsite.tar" }));
render(<DBBackupCard />);
expect(await screen.findByText("Off-site copy snapshot")).toBeTruthy();
});
});
+34 -1
View File
@@ -19,10 +19,14 @@ const LABEL_KEY: Record<DBBackupLabel, string> = {
daily: "dbbackup_label_daily",
"pre-migrate": "dbbackup_label_pre_migrate",
"pre-restore": "dbbackup_label_pre_restore",
offsite: "dbbackup_label_offsite",
manual: "dbbackup_label_manual",
};
const FIX_COMMANDS = ["sudo felis db backup", "journalctl -u felis-db-backup -n 50 --no-pager"];
// A bundle the cluster did not add its MinecraftServer objects to: see why the
// cluster did not answer, then take a whole one.
const SERVERS_COMMANDS = ["sudo k3s kubectl get minecraftservers -A", "sudo felis db backup"];
export function CopyCommand({ command }: { command: string }) {
const { t } = useTranslation("admin");
@@ -73,6 +77,7 @@ export function DBBackupCard() {
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 serversError = last?.servers_error ?? "";
const state: "loading" | "error" | "never" | "stale" | "ok" = !data
? error
@@ -87,6 +92,14 @@ export function DBBackupCard() {
const badge = (() => {
switch (state) {
case "ok":
if (serversError) {
return (
<Badge className="gap-1 border-transparent bg-amber-500/15 text-amber-600 dark:text-amber-400">
<AlertTriangle className="h-3 w-3" />
{t("dbbackup_status_incomplete")}
</Badge>
);
}
return (
<Badge className="gap-1 border-transparent bg-emerald-500/15 text-emerald-500">
<CheckCircle2 className="h-3 w-3" />
@@ -121,7 +134,9 @@ export function DBBackupCard() {
"hidden rounded-md p-2 sm:block",
state === "stale" || state === "never"
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
: serversError
? "bg-amber-500/10 text-amber-600 dark:text-amber-400"
: "bg-primary/10 text-primary",
)}
>
<Database className="h-5 w-5" />
@@ -205,6 +220,24 @@ export function DBBackupCard() {
</div>
)}
{serversError && (
<div className="space-y-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<div className="flex items-start gap-2 text-amber-800 dark:text-amber-200">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 space-y-1">
<p className="font-semibold">{t("dbbackup_servers_title")}</p>
<p className="text-xs leading-relaxed">{t("dbbackup_servers_hint")}</p>
<p className="break-all font-mono text-[11px] opacity-80">{serversError}</p>
</div>
</div>
<div className="space-y-2">
{SERVERS_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")}