From dee4d87fd1ff792ce60ef9372cd4dd79ad2ecc32 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 13:09:44 +0800 Subject: [PATCH] =?UTF-8?q?chore(panel):=20=E5=88=A0=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=20ServerCard=20=E4=B8=8E=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=8E=A7=E4=BB=B6=E7=9A=84=E5=8D=A1=E7=89=87=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E5=88=86=E6=94=AF=EF=BC=8C=E6=81=A2=E5=A4=8D=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=E6=94=B9=E4=B8=BA=E6=8C=89=E8=A1=8C=E5=8F=AF=E9=80=89?= =?UTF-8?q?=E4=BB=BB=E4=B8=80=E6=9C=AA=E8=BF=87=E6=9C=9F=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=EF=BC=8Cmock=20=E8=A1=A5=E4=B8=8A=E5=A4=87=E4=BB=BD=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E4=B8=8E=E7=AB=8B=E5=8D=B3=E5=A4=87=E4=BB=BD=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- panel/dev/mockApi.ts | 83 ++++++++++++++- panel/src/components/ServerCard.tsx | 109 -------------------- panel/src/i18n/resources/en-US/backups.json | 4 - panel/src/i18n/resources/zh-CN/backups.json | 4 - panel/src/pages/ServerBackups.tsx | 109 ++++++-------------- 5 files changed, 108 insertions(+), 201 deletions(-) delete mode 100644 panel/src/components/ServerCard.tsx diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 112eed6..8392b5e 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -11,6 +11,7 @@ import type { Identity, MyServerView, Phase, + ServerJob, ServerStatus, WhitelistImage, Submission, @@ -77,6 +78,8 @@ interface MockState { images: WhitelistImage[]; access: Record; backups: BackupView[]; + // Backup/restore Jobs by server, newest first. The mock finishes each at once. + jobs: Record; builds: Build[]; passkeys: Record; 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 { 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) { diff --git a/panel/src/components/ServerCard.tsx b/panel/src/components/ServerCard.tsx deleted file mode 100644 index 620e9c2..0000000 --- a/panel/src/components/ServerCard.tsx +++ /dev/null @@ -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); - const [error, setError] = useState(null); - const host = hostFor(server.subdomain, cfg); - - async function act(kind: "claim", fn: () => Promise) { - 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 ( - - {/* Header Row */} -
- - {server.displayName || server.name} - - -
- - {/* Info Row */} -
- - {host} - - -
- - {running - ? `${server.playersOnline ?? 0}${server.playersMax ? `/${server.playersMax}` : ""}` - : "—"} - - -
-
- - {error &&

{error}

} - - {/* Action Row */} -
- {server.owned && ( - - - - )} - {server.claimable && !server.owned && ( - - )} - {server.owned && ( - - )} -
-
- ); -} diff --git a/panel/src/i18n/resources/en-US/backups.json b/panel/src/i18n/resources/en-US/backups.json index 16f25d3..8b2fc1c 100644 --- a/panel/src/i18n/resources/en-US/backups.json +++ b/panel/src/i18n/resources/en-US/backups.json @@ -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", diff --git a/panel/src/i18n/resources/zh-CN/backups.json b/panel/src/i18n/resources/zh-CN/backups.json index 2490bb9..6b48e90 100644 --- a/panel/src/i18n/resources/zh-CN/backups.json +++ b/panel/src/i18n/resources/zh-CN/backups.json @@ -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": "世界目录里有符号链接等非普通文件,归档时跳过了它们,恢复后这些条目不会存在。", diff --git a/panel/src/pages/ServerBackups.tsx b/panel/src/pages/ServerBackups.tsx index 33a79f9..8257c97 100644 --- a/panel/src/pages/ServerBackups.tsx +++ b/panel/src/pages/ServerBackups.tsx @@ -137,8 +137,6 @@ function BackupRow({ locale={locale} onReloadStatus={onReloadStatus} buttonVariant={isLatest ? "destructive" : "outline"} - buttonSize="sm" - layout="row" /> ) : ( @@ -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(null); const [safety, setSafety] = useState(true); - if (backup.corrupt) { - if (layout === "row") return null; - return ( -

- {t("corrupt_cannot_restore")} -

- ); - } - - if (isExpired(backup.expires_at, now)) { - if (layout === "row") return null; - return ( -

- {t("expired_cannot_restore")} -

- ); - } - if (done) { - if (layout === "row") { - return ( -
- - {t(done === "snapshot" ? "restore_snapshot_started_short" : "restore_started_short")} -
- ); - } return ( -
- - {t(done === "snapshot" ? "restore_snapshot_started" : "restore_started")} +
+ + {t(done === "snapshot" ? "restore_snapshot_started_short" : "restore_started_short")}
); } @@ -393,8 +362,8 @@ function RestoreControls({ const trigger = ( - ); @@ -454,42 +423,24 @@ function RestoreControls({ ); - if (layout === "row") { - return ( - { - if (submitting) return; - if (next) setError(null); - setOpen(next); - }} - > - {trigger} - {dialogContent} - - ); - } - return ( -
- { - if (submitting) return; // locked while the stop→restore chain runs - if (next) setError(null); // fresh each open - setOpen(next); - }} - > - {trigger} - {dialogContent} - -
+ { + if (submitting) return; // locked while the stop→restore chain runs + if (next) setError(null); // fresh each open + setOpen(next); + }} + > + {trigger} + {dialogContent} + ); } /** 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. */