Loading panel/src/i18n/resources/en-US/backups.json +1 −0 Changes for panel/src/i18n/resources/en-US/backups.json: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ "backup_now": "Back up now", "backup_in_progress": "Starting backup…", "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.", "expires_in": "Expires {{when}}", "expired": "Expired", Loading panel/src/i18n/resources/zh-CN/backups.json +1 −0 Changes for panel/src/i18n/resources/zh-CN/backups.json: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ "backup_now": "立即备份", "backup_in_progress": "正在启动备份……", "backup_started": "已开始备份——进度可在下方“最近操作”中查看。", "backup_world_busy": "已有备份或回档在进行,等它结束再备份。", "backup_requires_stopped": "备份前需要先停止服务器。", "expires_in": "{{when}}过期", "expired": "已过期", Loading panel/src/pages/ServerBackups.test.tsx +63 −0 Changes for panel/src/pages/ServerBackups.test.tsx: 63 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import i18next from "i18next"; import { ServerBackups } from "./ServerBackups"; import { humanizeError } from "@/lib/api"; import type { BackupView } from "@/lib/types"; const calls = vi.hoisted(() => ({ Loading Loading @@ -154,3 +155,65 @@ describe("ServerBackups", () => { 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); }); }); panel/src/pages/ServerBackups.tsx +27 −7 Changes for panel/src/pages/ServerBackups.tsx: 27 added lines, 7 removed lines. Original line number Diff line number Diff line Loading @@ -27,12 +27,12 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { PhaseBadge } from "@/components/PhaseBadge"; import { Loading, ErrorState, EmptyState, NotYours } from "@/components/States"; import { PhaseBadge, pendingPower, shownPhase, startFailure } from "@/components/PhaseBadge"; import { Loading, ErrorState, EmptyState, NotYours, RefreshError } from "@/components/States"; import { PageHeader } from "@/components/PageHeader"; import { Pagination } from "@/components/Pagination"; 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 { canManage, ownershipPending } from "@/lib/ownership"; import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/format"; Loading Loading @@ -459,6 +459,16 @@ export function ServerBackups() { const { t, i18n } = useTranslation("backups"); const { isAdmin, loading: tierLoading } = useTier(); 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( () => (isAdmin ? Promise.resolve([]) : api.myServers()), [isAdmin, name], Loading Loading @@ -541,7 +551,7 @@ export function ServerBackups() { </> ); } if (statusQ.error) { if (statusQ.error && !statusQ.data) { return ( <> {back} Loading @@ -560,6 +570,11 @@ export function ServerBackups() { .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); const total = backupsQ.data?.total ?? 0; 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 = ( <PageHeader Loading @@ -573,9 +588,13 @@ export function ServerBackups() { size="sm" variant="outline" onClick={handleBackupNow} disabled={backingUp || statusQ.data.phase !== "Stopped"} disabled={backingUp || phase !== "Stopped" || worldBusy} title={ statusQ.data.phase !== "Stopped" ? t("backup_requires_stopped") : undefined phase !== "Stopped" ? t("backup_requires_stopped") : worldBusy ? t("backup_world_busy") : undefined } > {backingUp ? ( Loading @@ -586,7 +605,7 @@ export function ServerBackups() { {backingUp ? t("backup_in_progress") : t("backup_now")} </Button> )} <PhaseBadge phase={statusQ.data.phase} /> <PhaseBadge phase={phase} failure={startFailure(statusQ.data)} autoRestarts={statusQ.data.autoRestarts} /> </div> } className="mb-6" Loading @@ -597,6 +616,7 @@ export function ServerBackups() { <> {back} {header} {!!statusQ.error && <RefreshError error={statusQ.error} className="mb-4" />} {pending ? ( <Loading /> ) : mineQ.error ? ( Loading Loading
panel/src/i18n/resources/en-US/backups.json +1 −0 Changes for panel/src/i18n/resources/en-US/backups.json: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ "backup_now": "Back up now", "backup_in_progress": "Starting backup…", "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.", "expires_in": "Expires {{when}}", "expired": "Expired", Loading
panel/src/i18n/resources/zh-CN/backups.json +1 −0 Changes for panel/src/i18n/resources/zh-CN/backups.json: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ "backup_now": "立即备份", "backup_in_progress": "正在启动备份……", "backup_started": "已开始备份——进度可在下方“最近操作”中查看。", "backup_world_busy": "已有备份或回档在进行,等它结束再备份。", "backup_requires_stopped": "备份前需要先停止服务器。", "expires_in": "{{when}}过期", "expired": "已过期", Loading
panel/src/pages/ServerBackups.test.tsx +63 −0 Changes for panel/src/pages/ServerBackups.test.tsx: 63 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import i18next from "i18next"; import { ServerBackups } from "./ServerBackups"; import { humanizeError } from "@/lib/api"; import type { BackupView } from "@/lib/types"; const calls = vi.hoisted(() => ({ Loading Loading @@ -154,3 +155,65 @@ describe("ServerBackups", () => { 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); }); });
panel/src/pages/ServerBackups.tsx +27 −7 Changes for panel/src/pages/ServerBackups.tsx: 27 added lines, 7 removed lines. Original line number Diff line number Diff line Loading @@ -27,12 +27,12 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { PhaseBadge } from "@/components/PhaseBadge"; import { Loading, ErrorState, EmptyState, NotYours } from "@/components/States"; import { PhaseBadge, pendingPower, shownPhase, startFailure } from "@/components/PhaseBadge"; import { Loading, ErrorState, EmptyState, NotYours, RefreshError } from "@/components/States"; import { PageHeader } from "@/components/PageHeader"; import { Pagination } from "@/components/Pagination"; 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 { canManage, ownershipPending } from "@/lib/ownership"; import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/format"; Loading Loading @@ -459,6 +459,16 @@ export function ServerBackups() { const { t, i18n } = useTranslation("backups"); const { isAdmin, loading: tierLoading } = useTier(); 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( () => (isAdmin ? Promise.resolve([]) : api.myServers()), [isAdmin, name], Loading Loading @@ -541,7 +551,7 @@ export function ServerBackups() { </> ); } if (statusQ.error) { if (statusQ.error && !statusQ.data) { return ( <> {back} Loading @@ -560,6 +570,11 @@ export function ServerBackups() { .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); const total = backupsQ.data?.total ?? 0; 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 = ( <PageHeader Loading @@ -573,9 +588,13 @@ export function ServerBackups() { size="sm" variant="outline" onClick={handleBackupNow} disabled={backingUp || statusQ.data.phase !== "Stopped"} disabled={backingUp || phase !== "Stopped" || worldBusy} title={ statusQ.data.phase !== "Stopped" ? t("backup_requires_stopped") : undefined phase !== "Stopped" ? t("backup_requires_stopped") : worldBusy ? t("backup_world_busy") : undefined } > {backingUp ? ( Loading @@ -586,7 +605,7 @@ export function ServerBackups() { {backingUp ? t("backup_in_progress") : t("backup_now")} </Button> )} <PhaseBadge phase={statusQ.data.phase} /> <PhaseBadge phase={phase} failure={startFailure(statusQ.data)} autoRestarts={statusQ.data.autoRestarts} /> </div> } className="mb-6" Loading @@ -597,6 +616,7 @@ export function ServerBackups() { <> {back} {header} {!!statusQ.error && <RefreshError error={statusQ.error} className="mb-4" />} {pending ? ( <Loading /> ) : mineQ.error ? ( Loading