chore(panel): 删除未使用的 ServerCard 与恢复控件的卡片布局分支,恢复注释改为按行可选任一未过期备份,mock 补上备份任务与立即备份路由

This commit is contained in:
Lemon-miaow committed 2026-09-25 13:09:44 +08:00
1 parent 9f60178ba8
commit dee4d87fd1
5 files changed
+108 -201

No files matched your search

+78 -5
View File
@@ -11,6 +11,7 @@ import type {
Identity,
MyServerView,
Phase,
ServerJob,
ServerStatus,
WhitelistImage,
Submission,
@@ -77,6 +78,8 @@ interface MockState {
images: WhitelistImage[];
access: Record<string, AccessState>;
backups: BackupView[];
// Backup/restore Jobs by server, newest first. The mock finishes each at once.
jobs: Record<string, ServerJob[]>;
builds: Build[];
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
submissions: Submission[];
@@ -189,14 +192,23 @@ function sha256Hex(b: Buffer): string {
}
const RETENTION_DAYS = 90;
function backup(server: string, daysAgo: number, sizeBytes: number, formerOwner: string): BackupView {
let freshBackups = 0;
function backup(
server: string,
daysAgo: number,
sizeBytes: number,
formerOwner: string,
reason = "inactive_15d",
): BackupView {
const created = Date.now() - daysAgo * DAY_MS;
return {
id: `bk-${server}-${daysAgo}`,
// A seeded row is named by its age; one made now needs a sequence to stay unique.
id: daysAgo > 0 ? `bk-${server}-${daysAgo}` : `bk-${server}-now-${++freshBackups}`,
server_name: server,
former_owner: formerOwner,
size_bytes: Math.round(sizeBytes),
reason: "inactive_15d",
reason,
status: "present",
created_at: new Date(created).toISOString(),
expires_at: new Date(created + RETENTION_DAYS * DAY_MS).toISOString(),
@@ -210,6 +222,9 @@ function mockBackups(): BackupView[] {
backup("survival", 45, 1.2 * GiB, "owner"),
backup("survival", 88, 2.1 * GiB, "owner"), // ~2 days from expiry — exercises the urgency state
backup("modded", 12, 0.6 * GiB, "owner"),
// The linked player's own server, so a player sees the restore rows too.
backup("lobby", 2, 0.8 * GiB, "linked", "manual"),
backup("lobby", 30, 0.7 * GiB, "linked"),
];
}
@@ -304,6 +319,7 @@ function initialState(): MockState {
},
},
backups: mockBackups(),
jobs: {},
builds: [
{
id: "bld-1",
@@ -1805,6 +1821,18 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
if (is("POST", ctx) && ctx.parts[4] === "restore-backup") {
return await handleRestoreBackupMock(ctx, serverInfo);
}
if (is("POST", ctx) && ctx.parts[4] === "backup") {
handleBackupNowMock(ctx, serverInfo);
return true;
}
if (is("GET", ctx) && ctx.parts[4] === "jobs") {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
sendJSON(ctx.res, 200, { server: serverInfo.name, jobs: ctx.state.jobs[serverInfo.name] ?? [] });
return true;
}
if (ctx.parts[4] === "access") {
return handleAccessMock(ctx, serverInfo);
}
@@ -1822,9 +1850,11 @@ async function handleRestoreBackupMock(ctx: SessionContext, serverInfo: MockServ
return true;
}
let backupId: string | undefined;
let safety = true;
try {
const body = await readJSON<{ backup_id?: string }>(ctx.req);
const body = await readJSON<{ backup_id?: string; safety_snapshot?: boolean }>(ctx.req);
backupId = body.backup_id;
safety = body.safety_snapshot !== false;
} catch (e) {
// Ignore if body is empty or unparsable
}
@@ -1857,10 +1887,53 @@ async function handleRestoreBackupMock(ctx: SessionContext, serverInfo: MockServ
sendError(ctx.res, 409, "not_stopped", "stop the server before restoring a backup");
return true;
}
sendJSON(ctx.res, 202, { name: serverInfo.name, status: "restoring", backup_id: backup.id });
const now = new Date().toISOString();
const done = { state: "succeeded", started_at: now, finished_at: now };
const jobs: ServerJob[] = [{ name: `restore-${serverInfo.name}-${Date.now()}`, kind: "restore", ...done }];
if (safety) {
ctx.state.backups.unshift(backup(serverInfo.name, 0, 0.9 * GiB, ctx.account.id, "pre_restore"));
jobs.push({
name: `backup-${serverInfo.name}-${Date.now()}`,
kind: "backup",
...done,
then_restore: "started",
restore_backup_id: backup.id,
});
}
ctx.state.jobs[serverInfo.name] = [...jobs, ...(ctx.state.jobs[serverInfo.name] ?? [])];
sendJSON(ctx.res, 202, {
name: serverInfo.name,
status: "restoring",
backup_id: backup.id,
safety_snapshot: safety,
});
return true;
}
// handleBackupNowMock mirrors POST /servers/{name}/backup: owner-or-admin, then
// the stopped gate (the world volume is RWO), then 202. The Job finishes at once.
function handleBackupNowMock(ctx: SessionContext, serverInfo: MockServer): void {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return;
}
if (serverInfo.phase !== "Stopped") {
sendError(ctx.res, 409, "not_stopped", "stop the server before backing it up");
return;
}
const now = new Date().toISOString();
ctx.state.backups.unshift(backup(serverInfo.name, 0, 0.9 * GiB, ctx.account.id, "manual"));
const job: ServerJob = {
name: `backup-${serverInfo.name}-${Date.now()}`,
kind: "backup",
state: "succeeded",
started_at: now,
finished_at: now,
};
ctx.state.jobs[serverInfo.name] = [job, ...(ctx.state.jobs[serverInfo.name] ?? [])];
sendJSON(ctx.res, 202, { name: serverInfo.name, status: "backing_up" });
}
function accessFor(state: MockState, name: string): AccessState {
let entry = state.access[name];
if (!entry) {
-109
View File
@@ -1,109 +0,0 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Hand, Users, ExternalLink, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton";
import { api, humanizeError } from "@/lib/api";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import type { MyServerView } from "@/lib/types";
interface Props {
server: MyServerView;
cfg: RuntimeConfig;
/** Called after a successful mutation so the parent can refetch. */
onChanged: () => void;
}
export function ServerCard({ server, cfg, onChanged }: Props) {
const { t } = useTranslation("servers");
const [busy, setBusy] = useState<null | "claim">(null);
const [error, setError] = useState<string | null>(null);
const host = hostFor(server.subdomain, cfg);
async function act(kind: "claim", fn: () => Promise<unknown>) {
setBusy(kind);
setError(null);
try {
await fn();
onChanged();
} catch (e) {
setError(humanizeError(e));
} finally {
setBusy(null);
}
}
const running = server.phase === "Running";
// Starting and Stopping both have (or had) a pod in flight — neither should
// offer "Wake". Grouping them keeps the footer on the Stop side until the
// server has fully settled into Stopped/Failed.
const transitioning = server.phase === "Starting" || server.phase === "Stopping";
return (
<Card className="flex flex-col p-4 transition-colors hover:border-primary/40">
{/* Header Row */}
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-foreground">
{server.displayName || server.name}
</span>
<PhaseBadge phase={server.phase ?? "Unknown"} />
</div>
{/* Info Row */}
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<a
href={`https://${host}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground truncate max-w-[70%]"
>
{host}
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
<div className="flex items-center gap-1.5 shrink-0 mr-1">
<span>
{running
? `${server.playersOnline ?? 0}${server.playersMax ? `/${server.playersMax}` : ""}`
: "—"}
</span>
<Users className="h-3.5 w-3.5" />
</div>
</div>
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
{/* Action Row */}
<div className="mt-4 flex flex-wrap gap-2 border-t border-border/50 pt-3">
{server.owned && (
<Link to={`/servers/${server.name}`}>
<Button variant="outline" size="sm">
<Terminal /> {t("console")}
</Button>
</Link>
)}
{server.claimable && !server.owned && (
<Button
size="sm"
variant="outline"
disabled={busy !== null}
onClick={() => act("claim", () => api.claim(server.name))}
>
<Hand /> {busy === "claim" ? t("claiming") : t("claim")}
</Button>
)}
{server.owned && (
<PowerButton
name={server.name}
live={running || transitioning}
playersOnline={server.playersOnline}
playerCountUnknown={server.playerCountUnknown}
onChanged={onChanged}
/>
)}
</div>
</Card>
);
}
@@ -47,18 +47,14 @@
"restore_confirm_yes": "Confirm restore",
"restore_confirm_yes_unsafe": "Overwrite and restore",
"cancel": "Cancel",
"restore_started": "Restore started — the world is being rebuilt from this backup. Wake the server once it finishes to see the restored world.",
"restore_snapshot_started": "Taking a safety snapshot of the current world — the chosen backup is restored automatically once it finishes; follow it under recent operations below. Wake the server once the restore finishes.",
"restore_started_short": "Restore started",
"restore_snapshot_started_short": "Snapshot, then restore",
"stopping": "Stopping the server…",
"restoring": "Restoring…",
"stop_timeout": "The server did not stop in time. Close this window and try again shortly.",
"expired_cannot_restore": "This backup has expired and can no longer be restored.",
"corrupt_badge": "Corrupt",
"corrupt_hint": "A read-back found this archive no longer matches what was written, so it can't be restored intact. It stays listed until it expires so the loss is visible.",
"corrupt_short": "Corrupt",
"corrupt_cannot_restore": "This backup failed its read-back check and can't be restored — pick another backup.",
"verified_at": "Verified {{when}}",
"skipped_entries_one": "{{count}} entry not archived",
"skipped_entries_other": "{{count}} entries not archived",
@@ -47,18 +47,14 @@
"restore_confirm_yes": "确认恢复",
"restore_confirm_yes_unsafe": "直接覆盖并恢复",
"cancel": "取消",
"restore_started": "已开始恢复备份——正在用此备份重建世界。完成后启动服务器即可看到恢复后的世界。",
"restore_snapshot_started": "已开始为当前世界做安全快照——快照完成后会自动用所选备份恢复,进度见下方“最近操作”。恢复完成后启动服务器即可看到恢复后的世界。",
"restore_started_short": "已启动恢复",
"restore_snapshot_started_short": "快照中,随后恢复",
"stopping": "正在停止服务器……",
"restoring": "正在恢复备份……",
"stop_timeout": "服务器停止超时。请关闭此窗口,稍后重试。",
"expired_cannot_restore": "此备份已过期,无法恢复。",
"corrupt_badge": "已损坏",
"corrupt_hint": "回读校验发现这份归档与写入时不一致,已无法完整恢复。它会保留到过期,方便排查。",
"corrupt_short": "已损坏",
"corrupt_cannot_restore": "这份备份回读校验未通过,无法恢复,请选择另一份备份。",
"verified_at": "{{when}}已校验",
"skipped_entries": "{{count}} 个条目未归档",
"skipped_entries_hint": "世界目录里有符号链接等非普通文件,归档时跳过了它们,恢复后这些条目不会存在。",
+30 -79
View File
@@ -137,8 +137,6 @@ function BackupRow({
locale={locale}
onReloadStatus={onReloadStatus}
buttonVariant={isLatest ? "destructive" : "outline"}
buttonSize="sm"
layout="row"
/>
) : (
<span className="text-xs text-muted-foreground/40 font-medium px-3 py-1.5">
@@ -263,14 +261,15 @@ function ChainNote({ job }: { job: ServerJob }) {
return null;
}
/** RestoreControls is the restore ACTION on each unexpired backup row. Restore
* overwrites the world, so it hides behind a single button that opens a confirm
* dialog. The dialog states the full cost up front — the server is stopped (online
* players drop) and the current world is overwritten by THIS exact backup — and
* offers a safety snapshot, on by default: the backend first backs up the world as
* it is and restores only once that succeeded, which makes a wrong pick undoable.
* Turning it off brings back the irreversible wording. On confirm it runs the whole
* chain itself: stop → wait for Stopped → restore.
/** RestoreControls is the restore button on each backup row that can still be
* restored (the row renders a note in its place for a corrupt or expired one), and
* any of them may be picked, not only the newest. Restore overwrites the world, so
* the button opens a confirm dialog that states the full cost up front: the server is
* stopped (online players drop) and the current world is overwritten by THIS exact
* backup. It offers a safety snapshot, on by default: the backend first backs up the
* world as it is and restores only once that succeeded, which makes a wrong pick
* undoable. Turning it off brings back the irreversible wording. On confirm it runs
* the whole chain itself: stop → wait for Stopped → restore.
* The backend refuses a restore unless the world volume is free (409 not_stopped), so
* stopping here means the user never has to detour to the console and come back. There
* is deliberately no type-the-name step: the friction that matters is owning the
@@ -280,8 +279,8 @@ function ChainNote({ job }: { job: ServerJob }) {
* status until Stopped is observed, giving up after ~60s with a retryable timeout — so
* restore only fires once the volume is provably free. While the chain runs the dialog
* is locked (no ✕, no dismiss) so a mid-flight close can't strand it. A 202 is
* terminal: the dialog closes and the card shows a "restore started" note instead of
* re-offering the trigger, so a second restore Job can't race the first. */
* terminal: the dialog closes and the row shows a "restore started" note in place of
* the button, so a second restore Job can't race the first. */
const POLL_MS = 2500;
const MAX_POLLS = 24; // ~60s ceiling before we stop waiting for Stopped
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -293,17 +292,13 @@ function RestoreControls({
locale,
onReloadStatus,
buttonVariant = "destructive",
buttonSize = "sm",
layout = "card",
}: {
serverName: string;
backup: BackupView;
now: number;
locale: string;
onReloadStatus: () => void;
buttonVariant?: "destructive" | "outline" | "ghost" | "default";
buttonSize?: "default" | "sm" | "lg" | "icon";
layout?: "card" | "row";
buttonVariant?: "destructive" | "outline";
}) {
const { t } = useTranslation("backups");
const [open, setOpen] = useState(false);
@@ -317,37 +312,11 @@ function RestoreControls({
const [error, setError] = useState<string | null>(null);
const [safety, setSafety] = useState(true);
if (backup.corrupt) {
if (layout === "row") return null;
return (
<p className="mt-4 border-t border-primary/20 pt-4 text-xs text-destructive">
{t("corrupt_cannot_restore")}
</p>
);
}
if (isExpired(backup.expires_at, now)) {
if (layout === "row") return null;
return (
<p className="mt-4 border-t border-primary/20 pt-4 text-xs text-destructive">
{t("expired_cannot_restore")}
</p>
);
}
if (done) {
if (layout === "row") {
return (
<div className="flex items-center gap-1.5 text-xs text-emerald-500 font-medium">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<span>{t(done === "snapshot" ? "restore_snapshot_started_short" : "restore_started_short")}</span>
</div>
);
}
return (
<div className="mt-4 flex items-start gap-2 border-t border-primary/20 pt-4 text-sm text-emerald-500">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t(done === "snapshot" ? "restore_snapshot_started" : "restore_started")}</span>
<div className="flex items-center gap-1.5 text-xs text-emerald-500 font-medium">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<span>{t(done === "snapshot" ? "restore_snapshot_started_short" : "restore_started_short")}</span>
</div>
);
}
@@ -393,8 +362,8 @@ function RestoreControls({
const trigger = (
<DialogTrigger asChild>
<Button variant={buttonVariant} size={buttonSize}>
<RotateCcw className="h-4 w-4" /> {t(layout === "row" ? "restore_btn_short" : "restore_btn")}
<Button variant={buttonVariant} size="sm">
<RotateCcw className="h-4 w-4" /> {t("restore_btn_short")}
</Button>
</DialogTrigger>
);
@@ -454,42 +423,24 @@ function RestoreControls({
</DialogContent>
);
if (layout === "row") {
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (submitting) return;
if (next) setError(null);
setOpen(next);
}}
>
{trigger}
{dialogContent}
</Dialog>
);
}
return (
<div className="mt-4 border-t border-primary/20 pt-4">
<Dialog
open={open}
onOpenChange={(next) => {
if (submitting) return; // locked while the stop→restore chain runs
if (next) setError(null); // fresh each open
setOpen(next);
}}
>
{trigger}
{dialogContent}
</Dialog>
</div>
<Dialog
open={open}
onOpenChange={(next) => {
if (submitting) return; // locked while the stop→restore chain runs
if (next) setError(null); // fresh each open
setOpen(next);
}}
>
{trigger}
{dialogContent}
</Dialog>
);
}
/** ServerBackups is the per-server backup surface (/servers/:name/backups): view the
* world archives kept for this server and (B2) roll the world back to the most
* recent one. It owns its own gating — ownership from /me/servers, since GET status
* world archives kept for this server and (B2) roll the world back to any of them
* that has not expired. It owns its own gating — ownership from /me/servers, since GET status
* never carries `owned` — but deliberately does NOT gate on readiness the way
* ServerPlayers does: backups are read from Postgres, not RCON, and a restore in
* fact requires the server to be STOPPED, so this page must work while it is asleep. */