feat(panel): add back up now and recent operations to the backups page

The backups page could list and restore archives but not create one,
and nothing surfaced backup/restore Job outcomes — a failed 202 was
visible only through kubectl. Add a Back up now action (enabled only on
a stopped server, the backend's own gate; a raced 409 is surfaced in
its words) and a Recent operations card fed by GET /servers/{name}/jobs
that shows running/succeeded/failed with the Job's failure message,
re-reads on an interval while a Job is running, and persists across
reloads. Wire-shape tests pin both endpoints.
This commit is contained in:
Lemon-miaow committed 2026-09-23 06:20:16 +08:00
1 parent 2010961d32
commit 97a64c8a33
6 files changed
+255 -12

No files matched your search

+12 -1
View File
@@ -9,11 +9,22 @@
"reason_inactive": "Idle archive",
"reason_manual": "Manual backup",
"reason_label": "Reason: {{reason}}",
"backup_now": "Back up now",
"backup_in_progress": "Starting backup…",
"backup_started": "Backup started — its progress shows under recent operations below.",
"backup_requires_stopped": "Stop the server before backing it up.",
"expires_in": "Expires {{when}}",
"expired": "Expired",
"former_owner": "Former owner: {{owner}}",
"empty_title": "No backups for this server",
"empty_hint": "A backup is saved only when a world is archived after long inactivity.",
"empty_hint": "A backup is saved when a world is archived after long inactivity — or create one now with “Back up now”.",
"jobs_title": "Recent operations",
"jobs_empty": "No recent backup or restore operations.",
"job_backup": "Backup",
"job_restore": "Restore",
"job_running": "Running",
"job_succeeded": "Succeeded",
"job_failed": "Failed",
"restore_btn": "Restore this backup",
"restore_btn_short": "Restore",
"restore_confirm": "Restoring a backup will stop the server (all online players will be disconnected) and completely overwrite the current world with this backup. This operation is irreversible. Do you want to continue?",
+12 -1
View File
@@ -9,11 +9,22 @@
"reason_inactive": "闲置自动回收",
"reason_manual": "手动备份",
"reason_label": "原因:{{reason}}",
"backup_now": "立即备份",
"backup_in_progress": "正在启动备份……",
"backup_started": "已开始备份——进度可在下方“最近操作”中查看。",
"backup_requires_stopped": "备份前需要先停止服务器。",
"expires_in": "{{when}}过期",
"expired": "已过期",
"former_owner": "原世界所有者:{{owner}}",
"empty_title": "暂无备份",
"empty_hint": "仅在服务器长期闲置被系统自动回收时才会生成备份。",
"empty_hint": "服务器长期闲置被自动回收时会生成备份,也可以点“立即备份”马上创建。",
"jobs_title": "最近操作",
"jobs_empty": "暂无备份或恢复操作记录。",
"job_backup": "备份",
"job_restore": "恢复",
"job_running": "进行中",
"job_succeeded": "成功",
"job_failed": "失败",
"restore_btn": "恢复此备份",
"restore_btn_short": "恢复",
"restore_confirm": "恢复备份将停止服务器(所有在线玩家将被断开),并用该备份完全覆盖当前世界的全部数据。此操作不可逆,请确认是否继续?",
+32
View File
@@ -549,4 +549,36 @@ describe("image whitelist and builds wire shapes", () => {
expect(JSON.parse((opts as RequestInit).body as string)).toEqual(win);
});
});
describe("backup now and server jobs wire shapes", () => {
it("backupNow POSTs to /servers/{name}/backup with no body and parses the 202", async () => {
const fetchSpy = fakeFetch({ name: "survival", status: "backing_up" }, { status: 202 });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.backupNow("survival");
expect(res.status).toBe("backing_up");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/servers/survival/backup");
expect((opts as RequestInit).method).toBe("POST");
expect((opts as RequestInit).body).toBeUndefined();
});
it("serverJobs GETs /servers/{name}/jobs and unwraps the jobs array", async () => {
const job = {
name: "felis-backup-survival-123",
kind: "backup",
state: "failed",
message: "pod terminated",
started_at: "2026-07-03T12:00:00Z",
};
const fetchSpy = fakeFetch({ server: "survival", jobs: [job] });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.serverJobs("survival");
expect(res).toHaveLength(1);
expect(res[0].state).toBe("failed");
expect(res[0].message).toBe("pod terminated");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/servers/survival/jobs");
expect((opts as RequestInit).method).toBe("GET");
});
});
});
+20
View File
@@ -17,6 +17,7 @@ import type {
PlayersResult,
QuotaInput,
QuotaView,
ServerJob,
ServerInfo,
SessionView,
UserDetail,
@@ -339,6 +340,25 @@ export const api = {
backupId ? { backup_id: backupId } : undefined,
),
// backupNow enqueues a manual backup (spec §7 POST backup). Preconditions are
// enforced server-side and surfaced as codes: owner-or-admin (403) and the
// server MUST be fully stopped (409 not_stopped — the world volume is RWO), so
// callers gate the action on phase === "Stopped". The reply is 202
// {name, status:"backing_up"}: the Job is enqueued, not done — watch
// serverJobs for the outcome.
backupNow: (name: string) =>
request<{ name: string; status: string }>("POST", `/servers/${name}/backup`),
// serverJobs lists the newest backup/restore Jobs of one server, newest first
// (GET /servers/{name}/jobs). Owner-or-admin gated server-side; a Job's
// failure text rides `message`. The backend answers 503 until the job-status
// reader is wired, so callers should tolerate that error.
serverJobs: (name: string) =>
request<{ server: string; jobs: ServerJob[] }>(
"GET",
`/servers/${name}/jobs`,
).then((r) => r.jobs ?? []),
// Account linking (spec §10). Both are POST: start reports status from the
// session principal (no body, side-effect-free), verify consumes a code the
// player was shown in-game. The panel can never mint a code — that is the
+12
View File
@@ -146,6 +146,18 @@ export interface BackupView {
expires_at: string;
}
/** ServerJob is one row of GET /api/v1/servers/{name}/jobs — the observable
* outcome of an async backup/restore Job. The API only enqueues Jobs, so this
* projection is how a 202 that later failed becomes visible in the panel. */
export interface ServerJob {
name: string;
kind: string; // "backup" | "restore"
state: string; // "running" | "succeeded" | "failed"
message?: string;
started_at?: string;
finished_at?: string;
}
/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
export interface WhitelistImage {
image_ref: string;
+167 -10
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import {
Archive,
@@ -8,12 +8,14 @@ import {
Loader2,
RotateCcw,
UserMinus,
XCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine } from "@/components/MessageLine";
import {
Dialog,
DialogContent,
@@ -134,6 +136,37 @@ function BackupRow({
);
}
/** JobStateBadge renders one async Job's state (backup/restore Job history). The
* state vocabulary is the API's ("running" | "succeeded" | "failed"); anything
* unknown is shown verbatim rather than hidden. */
function JobStateBadge({ state }: { state: string }) {
const { t } = useTranslation("backups");
if (state === "running") {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-medium text-sky-500 ring-1 ring-inset ring-sky-500/20">
<Loader2 className="h-3 w-3 animate-spin" />
{t("job_running")}
</span>
);
}
if (state === "succeeded") {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-500 ring-1 ring-inset ring-emerald-500/20">
<CheckCircle2 className="h-3 w-3" />
{t("job_succeeded")}
</span>
);
}
if (state === "failed") {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium text-destructive ring-1 ring-inset ring-destructive/20">
<XCircle className="h-3 w-3" />
{t("job_failed")}
</span>
);
}
return <span className="text-[10px] font-medium text-muted-foreground">{state}</span>;
}
/** RestoreControls is the restore ACTION, living only on the latest backup card.
@@ -339,6 +372,50 @@ export function ServerBackups() {
);
const backupsQ = useAsync(() => api.listBackups(), []);
// Ownership resolves from /me/servers for a non-admin (status carries no `owned`).
// While it is pending show the header with a spinner rather than flashing the list
// at someone who may not own it; if that read itself failed, break to a retry so a
// real owner never fails closed to NotYours on a transient blip.
const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error);
const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true);
// The async world-operation history (the backup/restore Jobs behind every 202).
// Read only once the viewer is resolved as owner-or-admin (the route 403s
// otherwise); while anything is still running it re-reads on an interval so the
// enqueue converges to succeeded/failed here instead of only in kubectl.
const jobsQ = useAsync(
() => (owned ? api.serverJobs(name) : Promise.resolve([])),
[name, owned],
);
useEffect(() => {
if (!(jobsQ.data ?? []).some((j) => j.state === "running")) return;
const id = setInterval(jobsQ.reload, 5000);
return () => clearInterval(id);
}, [jobsQ.data, jobsQ.reload]);
const [backingUp, setBackingUp] = useState(false);
const [backupMsg, setBackupMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
// Manual backup (POST /backup): the backend enforces the stopped gate, so the
// button only enables on Stopped and a raced 409 is surfaced in its own words.
async function handleBackupNow() {
if (backingUp) return;
setBackingUp(true);
setBackupMsg(null);
try {
await api.backupNow(name);
setBackupMsg({ kind: "success", text: t("backup_started") });
jobsQ.reload();
} catch (e: any) {
setBackupMsg({
kind: "error",
text: e && e.code === "not_stopped" ? t("backup_requires_stopped") : humanizeError(e),
});
} finally {
setBackingUp(false);
}
}
const back = (
<BackLink to={`/servers/${name}`} label={t("back_to_console")} />
);
@@ -361,13 +438,6 @@ export function ServerBackups() {
}
if (!statusQ.data) return back;
// Ownership resolves from /me/servers for a non-admin (status carries no `owned`).
// While it is pending show the header with a spinner rather than flashing the list
// at someone who may not own it; if that read itself failed, break to a retry so a
// real owner never fails closed to NotYours on a transient blip.
const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error);
const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true);
const now = Date.now();
const locale = i18n.language;
// The global list, narrowed to this server. Already created_at-descending from the
@@ -381,7 +451,29 @@ export function ServerBackups() {
icon={Archive}
title={statusQ.data.displayName || statusQ.data.name}
subtitle={t("title")}
actions={<PhaseBadge phase={statusQ.data.phase} />}
actions={
<div className="flex items-center gap-2">
{owned && (
<Button
size="sm"
variant="outline"
onClick={handleBackupNow}
disabled={backingUp || statusQ.data.phase !== "Stopped"}
title={
statusQ.data.phase !== "Stopped" ? t("backup_requires_stopped") : undefined
}
>
{backingUp ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Archive className="h-4 w-4" />
)}
{backingUp ? t("backup_in_progress") : t("backup_now")}
</Button>
)}
<PhaseBadge phase={statusQ.data.phase} />
</div>
}
className="mb-6"
/>
);
@@ -398,6 +490,7 @@ export function ServerBackups() {
<NotYours title={t("not_yours_title")} body={t("not_yours_body")} />
) : (
<div className="space-y-4">
{backupMsg && <MessageLine kind={backupMsg.kind} message={backupMsg.text} />}
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
{backupsQ.loading && !backupsQ.data ? (
<Loading />
@@ -429,7 +522,10 @@ export function ServerBackups() {
locale={locale}
showOwner={isAdmin}
serverName={name}
onReloadStatus={statusQ.reload}
onReloadStatus={() => {
statusQ.reload();
jobsQ.reload();
}}
/>
))}
</tbody>
@@ -444,6 +540,67 @@ export function ServerBackups() {
</Card>
</>
)}
{/* Async world-operation history: every backup/restore 202 lands here, so a
Job that later failed stays visible (with its message) without kubectl. */}
{jobsQ.error ? (
<ErrorState error={jobsQ.error} onRetry={jobsQ.reload} />
) : (
<Card className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
{t("jobs_title")}
</div>
{(jobsQ.data ?? []).length === 0 ? (
<p className="mt-2 text-xs text-muted-foreground">{t("jobs_empty")}</p>
) : (
<ul className="mt-2 divide-y divide-border">
{(jobsQ.data ?? []).map((j) => {
const at = j.started_at || j.finished_at;
return (
<li key={j.name} className="flex items-start justify-between gap-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
{j.kind === "restore" ? (
<RotateCcw className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
) : (
<Archive className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
)}
<span className="text-sm">
{j.kind === "restore"
? t("job_restore")
: j.kind === "backup"
? t("job_backup")
: j.kind}
</span>
{at && (
<span
className="text-xs text-muted-foreground"
title={formatAbsolute(at, locale)}
>
{formatRelative(at, now, locale)}
</span>
)}
</div>
<div className="flex min-w-0 flex-col items-end gap-0.5">
<JobStateBadge state={j.state} />
{j.state === "failed" && j.message && (
<span
className="max-w-[22rem] truncate text-xs text-destructive"
title={j.message}
>
{j.message}
</span>
)}
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
)}
</div>
)}
</>