fix(panel): 备份页轮询服务器状态,立即备份按去向判断并在已有备份或回档时等待,重读失败保留页面
This commit is contained in:
4 files changed
+92
-7
No files matched your search
@@ -15,6 +15,7 @@
|
|||||||
"backup_now": "Back up now",
|
"backup_now": "Back up now",
|
||||||
"backup_in_progress": "Starting backup…",
|
"backup_in_progress": "Starting backup…",
|
||||||
"backup_started": "Backup started — its progress shows under recent operations below.",
|
"backup_started": "Backup started — its progress shows under recent operations below.",
|
||||||
|
"backup_world_busy": "A backup or restore is already running; back up once it finishes.",
|
||||||
"backup_requires_stopped": "Stop the server before backing it up.",
|
"backup_requires_stopped": "Stop the server before backing it up.",
|
||||||
"expires_in": "Expires {{when}}",
|
"expires_in": "Expires {{when}}",
|
||||||
"expired": "Expired",
|
"expired": "Expired",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"backup_now": "立即备份",
|
"backup_now": "立即备份",
|
||||||
"backup_in_progress": "正在启动备份……",
|
"backup_in_progress": "正在启动备份……",
|
||||||
"backup_started": "已开始备份——进度可在下方“最近操作”中查看。",
|
"backup_started": "已开始备份——进度可在下方“最近操作”中查看。",
|
||||||
|
"backup_world_busy": "已有备份或回档在进行,等它结束再备份。",
|
||||||
"backup_requires_stopped": "备份前需要先停止服务器。",
|
"backup_requires_stopped": "备份前需要先停止服务器。",
|
||||||
"expires_in": "{{when}}过期",
|
"expires_in": "{{when}}过期",
|
||||||
"expired": "已过期",
|
"expired": "已过期",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import { ServerBackups } from "./ServerBackups";
|
import { ServerBackups } from "./ServerBackups";
|
||||||
|
import { humanizeError } from "@/lib/api";
|
||||||
import type { BackupView } from "@/lib/types";
|
import type { BackupView } from "@/lib/types";
|
||||||
|
|
||||||
const calls = vi.hoisted(() => ({
|
const calls = vi.hoisted(() => ({
|
||||||
@@ -154,3 +155,65 @@ describe("ServerBackups", () => {
|
|||||||
expect(screen.getByText(i18next.t("backups:stop_timeout"))).toBeTruthy();
|
expect(screen.getByText(i18next.t("backups:stop_timeout"))).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ServerBackups back up now", () => {
|
||||||
|
const backUp = () => screen.getByRole("button", { name: "Back up now" }) as HTMLButtonElement;
|
||||||
|
|
||||||
|
it("follows the server, so a stop lands on the page without a reload", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
calls.status.mockResolvedValue({ name: "survival", displayName: "Survival", phase: "Running", desiredState: "Running" });
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("Page 1 of 3");
|
||||||
|
expect(backUp().disabled).toBe(true);
|
||||||
|
expect(backUp().title).toBe("Stop the server before backing it up.");
|
||||||
|
|
||||||
|
calls.status.mockResolvedValue({ name: "survival", displayName: "Survival", phase: "Stopped", desiredState: "Stopped" });
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(14_000));
|
||||||
|
expect(backUp().disabled).toBe(true);
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(1_000));
|
||||||
|
expect(backUp().disabled).toBe(false);
|
||||||
|
expect(backUp().title).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rereads fast while the server is on its way somewhere", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
calls.status.mockResolvedValue({ name: "survival", displayName: "Survival", phase: "Stopping", desiredState: "Stopped" });
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("Page 1 of 3");
|
||||||
|
const before = calls.status.mock.calls.length;
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(4_000));
|
||||||
|
expect(calls.status.mock.calls.length).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a server just woken as starting and offers no backup", async () => {
|
||||||
|
calls.status.mockResolvedValue({ name: "survival", displayName: "Survival", phase: "Stopped", desiredState: "Running" });
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("Page 1 of 3");
|
||||||
|
expect(backUp().disabled).toBe(true);
|
||||||
|
expect(screen.getByText("Starting")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a backup running", { name: "backup-survival-aa", kind: "backup", state: "running" }],
|
||||||
|
// The safety snapshot is done and the restore it leads to has yet to start.
|
||||||
|
["a restore about to start", { name: "backup-survival-bb", kind: "backup", state: "succeeded", then_restore: "pending" }],
|
||||||
|
])("waits for %s", async (_, job) => {
|
||||||
|
calls.serverJobs.mockResolvedValue([job]);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("Page 1 of 3");
|
||||||
|
await vi.waitFor(() => expect(backUp().disabled).toBe(true));
|
||||||
|
expect(backUp().title).toBe("A backup or restore is already running; back up once it finishes.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the page when a reread fails, and says the status may be stale", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("Page 1 of 3");
|
||||||
|
const outage = { status: 503, code: "unavailable", message: "status unavailable" };
|
||||||
|
calls.status.mockRejectedValue(outage);
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(15_000));
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toContain(humanizeError(outage));
|
||||||
|
expect(screen.getByText("Page 1 of 3")).toBeTruthy();
|
||||||
|
expect(backUp().disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,12 +27,12 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
import { PhaseBadge, pendingPower, shownPhase, startFailure } from "@/components/PhaseBadge";
|
||||||
import { Loading, ErrorState, EmptyState, NotYours } from "@/components/States";
|
import { Loading, ErrorState, EmptyState, NotYours, RefreshError } from "@/components/States";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { Pagination } from "@/components/Pagination";
|
import { Pagination } from "@/components/Pagination";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { useAsync } from "@/lib/hooks";
|
import { useAsync, usePolling, STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { canManage, ownershipPending } from "@/lib/ownership";
|
import { canManage, ownershipPending } from "@/lib/ownership";
|
||||||
import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/format";
|
import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/format";
|
||||||
@@ -459,6 +459,16 @@ export function ServerBackups() {
|
|||||||
const { t, i18n } = useTranslation("backups");
|
const { t, i18n } = useTranslation("backups");
|
||||||
const { isAdmin, loading: tierLoading } = useTier();
|
const { isAdmin, loading: tierLoading } = useTier();
|
||||||
const statusQ = useAsync(() => api.status(name), [name]);
|
const statusQ = useAsync(() => api.status(name), [name]);
|
||||||
|
// "Back up now" and the badge follow the server, so a wake from the lobby or an
|
||||||
|
// idle stop shows here without a reload: reread fast while it is on its way
|
||||||
|
// somewhere, slow while nothing is due.
|
||||||
|
const moving =
|
||||||
|
!!statusQ.data &&
|
||||||
|
(statusQ.data.phase === "Starting" ||
|
||||||
|
statusQ.data.phase === "Stopping" ||
|
||||||
|
pendingPower(statusQ.data) !== null ||
|
||||||
|
startFailure(statusQ.data) === "retrying");
|
||||||
|
usePolling(statusQ.reload, moving ? STATUS_POLL_FAST_MS : STATUS_POLL_SLOW_MS);
|
||||||
const mineQ = useAsync(
|
const mineQ = useAsync(
|
||||||
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
|
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
|
||||||
[isAdmin, name],
|
[isAdmin, name],
|
||||||
@@ -541,7 +551,7 @@ export function ServerBackups() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (statusQ.error) {
|
if (statusQ.error && !statusQ.data) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{back}
|
{back}
|
||||||
@@ -560,6 +570,11 @@ export function ServerBackups() {
|
|||||||
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
||||||
const total = backupsQ.data?.total ?? 0;
|
const total = backupsQ.data?.total ?? 0;
|
||||||
const latestID = page === 1 ? all.find((b) => !b.corrupt)?.id : undefined;
|
const latestID = page === 1 ? all.find((b) => !b.corrupt)?.id : undefined;
|
||||||
|
// A server just woken still reads Stopped until its pod starts, so the gate is
|
||||||
|
// where it is heading. A backup or restore already under way holds the world
|
||||||
|
// (the API would answer 409), so the button waits for it too.
|
||||||
|
const phase = shownPhase(statusQ.data);
|
||||||
|
const worldBusy = (jobsQ.data ?? []).some((j) => j.state === "running" || j.then_restore === "pending");
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -573,9 +588,13 @@ export function ServerBackups() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleBackupNow}
|
onClick={handleBackupNow}
|
||||||
disabled={backingUp || statusQ.data.phase !== "Stopped"}
|
disabled={backingUp || phase !== "Stopped" || worldBusy}
|
||||||
title={
|
title={
|
||||||
statusQ.data.phase !== "Stopped" ? t("backup_requires_stopped") : undefined
|
phase !== "Stopped"
|
||||||
|
? t("backup_requires_stopped")
|
||||||
|
: worldBusy
|
||||||
|
? t("backup_world_busy")
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{backingUp ? (
|
{backingUp ? (
|
||||||
@@ -586,7 +605,7 @@ export function ServerBackups() {
|
|||||||
{backingUp ? t("backup_in_progress") : t("backup_now")}
|
{backingUp ? t("backup_in_progress") : t("backup_now")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PhaseBadge phase={statusQ.data.phase} />
|
<PhaseBadge phase={phase} failure={startFailure(statusQ.data)} autoRestarts={statusQ.data.autoRestarts} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
@@ -597,6 +616,7 @@ export function ServerBackups() {
|
|||||||
<>
|
<>
|
||||||
{back}
|
{back}
|
||||||
{header}
|
{header}
|
||||||
|
{!!statusQ.error && <RefreshError error={statusQ.error} className="mb-4" />}
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : mineQ.error ? (
|
) : mineQ.error ? (
|
||||||
|
|||||||
Reference in new issue
Block a user