fix(dbbackup): 备份新鲜度按最新的 daily 包算,手动或异地包不再掩盖停掉的定时任务

This commit is contained in:
Lemon-miaow committed 2026-09-27 16:40:50 +08:00
1 parent 46f99a7104
commit 840d339760
18 files changed
+262 -59

No files matched your search

+1
View File
@@ -1013,6 +1013,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
felis_version: "dev",
schema_version: 31,
dir: "/var/lib/felis/db-backups",
daily_at: at.toISOString(),
},
stale: false,
max_age_seconds: 26 * 3600,
+4 -1
View File
@@ -131,9 +131,12 @@
"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_daily_stale_title": "The daily backup last completed {{when}}, past the {{hours}}-hour limit",
"dbbackup_daily_never_title": "The daily timer has not completed a backup yet",
"dbbackup_daily_fix_hint": "The newer backup above was taken by hand or by another job and can be restored from, but it grows older every day the timer stays broken. Run the daily backup on the host now, then read its log to find out why it stopped:",
"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_fix_hint": "If the host failed now, accounts, server ownership and the archive index could not be recovered. Run the daily backup on the host now, then read its log to find out why the timer missed it:",
"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.",
"versions_title": "Component versions",
+4 -1
View File
@@ -131,9 +131,12 @@
"dbbackup_label_manual": "手动",
"dbbackup_never_title": "还没有记录到任何数据库备份",
"dbbackup_stale_title": "最近一次备份已超过 {{hours}} 小时",
"dbbackup_daily_stale_title": "每日定时备份最近一次完成是{{when}},已超过 {{hours}} 小时",
"dbbackup_daily_never_title": "每日定时任务还没有完成过一次备份",
"dbbackup_daily_fix_hint": "上面这份更新的备份来自手动或其他任务,可以用来恢复,但定时任务修好之前它会一天天变旧。在主机上立即运行一次每日备份,再查看日志找出它停下的原因:",
"dbbackup_servers_title": "最近一次备份缺少服务器定义",
"dbbackup_servers_hint": "备份时集群没有响应。用它恢复能找回账号和数据库,但一台服务器都不会有。等集群恢复响应后,在主机上重新备份一次:",
"dbbackup_fix_hint": "此时主机出故障,账号、服务器归属和存档索引都无法恢复。在主机上立即备份一次,再查看定时任务日志找出它没有运行的原因:",
"dbbackup_fix_hint": "此时主机出故障,账号、服务器归属和存档索引都无法恢复。在主机上立即运行一次每日备份,再查看它的日志找出定时任务错过的原因:",
"dbbackup_copy": "复制命令",
"dbbackup_offsite_note": "备份只保存在这台主机上,硬盘损坏或主机丢失时会一起丢失。请定期把备份目录复制到另一台机器;恢复与灾备步骤见故障排查文档 §16。",
"versions_title": "组件版本",
+7 -2
View File
@@ -1103,7 +1103,7 @@ export interface paths {
};
/**
* Freshness of the newest control-plane database backup (admin).
* @description What the host's felis-db-backup.timer (or a manual `felis db backup`) last recorded in platform_settings. last is null before the first backup; stale is true then, and whenever the newest backup is older than max_age_seconds. Read-only: backups run on the host, never through the API.
* @description What the host's felis-db-backup.timer (or a manual `felis db backup`) last recorded in platform_settings. last is null before the first backup; stale is true then, and whenever the newest daily backup (last.daily_at) is missing or older than max_age_seconds. Read-only: backups run on the host, never through the API.
*/
get: operations["getDBBackup"];
put?: never;
@@ -2343,8 +2343,13 @@ export interface components {
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;
/**
* Format: date-time
* @description When the newest daily bundle (felis-db-backup.timer) in dir was written, as of this record; absent when dir held none. Equal to at when this record is a daily one.
*/
daily_at?: string;
} | null;
/** @description True when there is no record or it is older than max_age_seconds. */
/** @description True when there is no record, no daily bundle, or the newest daily bundle is older than max_age_seconds. A newer manual, pre-migrate or off-site bundle leaves it as it is: the daily timer has still stopped. */
stale: boolean;
/**
* Format: int64
+3 -1
View File
@@ -469,12 +469,14 @@ export interface DBBackupRecord {
dir: string;
/** Why the bundle lacks the MinecraftServer objects, when it does: a restore from it brings back no servers. */
servers_error?: string;
/** When the newest daily bundle in dir was written, as of this record; absent when there was none. */
daily_at?: 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. */
/** True when there is no record, no daily bundle, or the newest daily bundle is older than max_age_seconds. */
stale: boolean;
max_age_seconds: number;
}
@@ -68,6 +68,38 @@ describe("DBBackupCard", () => {
expect(screen.getByText("The newest backup lacks the server definitions")).toBeTruthy();
});
it("turns an old daily backup red and hands over the daily unit", async () => {
const old = new Date(Date.now() - 30 * 3600 * 1000).toISOString();
calls.getDBBackup.mockResolvedValue(status({ at: old, daily_at: old }, true));
render(<DBBackupCard />);
expect(await screen.findByText("Overdue")).toBeTruthy();
expect(screen.getByText("The newest backup is more than 26 hours old")).toBeTruthy();
expect(screen.getByText(/find out why the timer missed it:$/)).toBeTruthy();
expect(screen.getByText("yesterday").className).toBe("text-destructive");
expect(screen.getByTitle("sudo systemctl start felis-db-backup.service")).toBeTruthy();
});
it("names the stopped daily timer under a fresh manual backup", async () => {
const daily = new Date(Date.now() - 50 * 3600 * 1000).toISOString();
calls.getDBBackup.mockResolvedValue(
status({ label: "manual", name: "felis-db-20260926T101500Z-manual.tar", daily_at: daily }, true),
);
render(<DBBackupCard />);
expect(await screen.findByText("Overdue")).toBeTruthy();
expect(screen.getByText("The daily backup last completed 2 days ago, past the 26-hour limit")).toBeTruthy();
expect(screen.getByText(/^The newer backup above was taken by hand or by another job and can be restored from/)).toBeTruthy();
// The manual bundle itself is fresh: its age stays out of the alarm.
expect(screen.getByText("3 hours ago").className).toBe("");
expect(screen.getByTitle("sudo systemctl start felis-db-backup.service")).toBeTruthy();
});
it("says the daily timer never completed when only other kinds exist", async () => {
calls.getDBBackup.mockResolvedValue(status({ label: "pre-migrate", name: "felis-db-20260926T101500Z-pre-migrate.tar" }, true));
render(<DBBackupCard />);
expect(await screen.findByText("The daily timer has not completed a backup yet")).toBeTruthy();
expect(screen.getByText(/^The newer backup above was taken by hand/)).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 />);
+20 -5
View File
@@ -23,7 +23,9 @@ const LABEL_KEY: Record<DBBackupLabel, string> = {
manual: "dbbackup_label_manual",
};
const FIX_COMMANDS = ["sudo felis db backup", "journalctl -u felis-db-backup -n 50 --no-pager"];
// The daily unit itself, run now: its bundle is what clears the alarm, and a
// failure lands in the log the second command reads.
const FIX_COMMANDS = ["sudo systemctl start felis-db-backup.service", "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"];
@@ -78,6 +80,10 @@ export function DBBackupCard() {
const last = data?.last ?? null;
const maxAgeHours = data ? Math.round(data.max_age_seconds / 3600) : 26;
const serversError = last?.servers_error ?? "";
// Stale goes by the daily timer's newest bundle. When the newest record is
// another kind, the alarm names the timer and leaves that bundle's own age
// alone: it can still be restored from.
const lastIsDaily = last?.label === "daily";
const state: "loading" | "error" | "never" | "stale" | "ok" = !data
? error
@@ -88,6 +94,15 @@ export function DBBackupCard() {
: data.stale
? "stale"
: "ok";
const dailyAlarm = state === "stale" && !lastIsDaily;
const staleTitle =
state === "never"
? t("dbbackup_never_title")
: !dailyAlarm
? t("dbbackup_stale_title", { hours: maxAgeHours })
: last?.daily_at
? t("dbbackup_daily_stale_title", { hours: maxAgeHours, when: formatRelative(last.daily_at, Date.now(), locale) })
: t("dbbackup_daily_never_title");
const badge = (() => {
switch (state) {
@@ -176,7 +191,7 @@ export function DBBackupCard() {
{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")}>
<span className={cn(state === "stale" && lastIsDaily && "text-destructive")}>
{formatRelative(last.at, Date.now(), locale) || "—"}
</span>
<span className="block truncate text-[11px] font-normal text-muted-foreground">
@@ -206,10 +221,10 @@ export function DBBackupCard() {
<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 className="font-semibold">{staleTitle}</p>
<p className="text-xs leading-relaxed text-destructive/90">
{dailyAlarm ? t("dbbackup_daily_fix_hint") : t("dbbackup_fix_hint")}
</p>
<p className="text-xs leading-relaxed text-destructive/90">{t("dbbackup_fix_hint")}</p>
</div>
</div>
<div className="space-y-2">