fix(dbbackup): 缺 MinecraftServer 导出时重试、CLI 失败退出、面板与 watchdog 告警,异地快照要求完整
This commit is contained in:
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。",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user