chore(panel): 删除未使用的 ServerCard 与恢复控件的卡片布局分支,恢复注释改为按行可选任一未过期备份,mock 补上备份任务与立即备份路由
This commit is contained in:
5 files changed
+94
-187
No files matched your search
+78
-5
@@ -11,6 +11,7 @@ import type {
|
|||||||
Identity,
|
Identity,
|
||||||
MyServerView,
|
MyServerView,
|
||||||
Phase,
|
Phase,
|
||||||
|
ServerJob,
|
||||||
ServerStatus,
|
ServerStatus,
|
||||||
WhitelistImage,
|
WhitelistImage,
|
||||||
Submission,
|
Submission,
|
||||||
@@ -77,6 +78,8 @@ interface MockState {
|
|||||||
images: WhitelistImage[];
|
images: WhitelistImage[];
|
||||||
access: Record<string, AccessState>;
|
access: Record<string, AccessState>;
|
||||||
backups: BackupView[];
|
backups: BackupView[];
|
||||||
|
// Backup/restore Jobs by server, newest first. The mock finishes each at once.
|
||||||
|
jobs: Record<string, ServerJob[]>;
|
||||||
builds: Build[];
|
builds: Build[];
|
||||||
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
|
||||||
submissions: Submission[];
|
submissions: Submission[];
|
||||||
@@ -189,14 +192,23 @@ function sha256Hex(b: Buffer): string {
|
|||||||
}
|
}
|
||||||
const RETENTION_DAYS = 90;
|
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;
|
const created = Date.now() - daysAgo * DAY_MS;
|
||||||
return {
|
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,
|
server_name: server,
|
||||||
former_owner: formerOwner,
|
former_owner: formerOwner,
|
||||||
size_bytes: Math.round(sizeBytes),
|
size_bytes: Math.round(sizeBytes),
|
||||||
reason: "inactive_15d",
|
reason,
|
||||||
status: "present",
|
status: "present",
|
||||||
created_at: new Date(created).toISOString(),
|
created_at: new Date(created).toISOString(),
|
||||||
expires_at: new Date(created + RETENTION_DAYS * DAY_MS).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", 45, 1.2 * GiB, "owner"),
|
||||||
backup("survival", 88, 2.1 * GiB, "owner"), // ~2 days from expiry — exercises the urgency state
|
backup("survival", 88, 2.1 * GiB, "owner"), // ~2 days from expiry — exercises the urgency state
|
||||||
backup("modded", 12, 0.6 * GiB, "owner"),
|
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(),
|
backups: mockBackups(),
|
||||||
|
jobs: {},
|
||||||
builds: [
|
builds: [
|
||||||
{
|
{
|
||||||
id: "bld-1",
|
id: "bld-1",
|
||||||
@@ -1805,6 +1821,18 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
if (is("POST", ctx) && ctx.parts[4] === "restore-backup") {
|
if (is("POST", ctx) && ctx.parts[4] === "restore-backup") {
|
||||||
return await handleRestoreBackupMock(ctx, serverInfo);
|
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") {
|
if (ctx.parts[4] === "access") {
|
||||||
return handleAccessMock(ctx, serverInfo);
|
return handleAccessMock(ctx, serverInfo);
|
||||||
}
|
}
|
||||||
@@ -1822,9 +1850,11 @@ async function handleRestoreBackupMock(ctx: SessionContext, serverInfo: MockServ
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let backupId: string | undefined;
|
let backupId: string | undefined;
|
||||||
|
let safety = true;
|
||||||
try {
|
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;
|
backupId = body.backup_id;
|
||||||
|
safety = body.safety_snapshot !== false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore if body is empty or unparsable
|
// 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");
|
sendError(ctx.res, 409, "not_stopped", "stop the server before restoring a backup");
|
||||||
return true;
|
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;
|
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 {
|
function accessFor(state: MockState, name: string): AccessState {
|
||||||
let entry = state.access[name];
|
let entry = state.access[name];
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
|
|||||||
@@ -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": "Confirm restore",
|
||||||
"restore_confirm_yes_unsafe": "Overwrite and restore",
|
"restore_confirm_yes_unsafe": "Overwrite and restore",
|
||||||
"cancel": "Cancel",
|
"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_started_short": "Restore started",
|
||||||
"restore_snapshot_started_short": "Snapshot, then restore",
|
"restore_snapshot_started_short": "Snapshot, then restore",
|
||||||
"stopping": "Stopping the server…",
|
"stopping": "Stopping the server…",
|
||||||
"restoring": "Restoring…",
|
"restoring": "Restoring…",
|
||||||
"stop_timeout": "The server did not stop in time. Close this window and try again shortly.",
|
"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_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_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_short": "Corrupt",
|
||||||
"corrupt_cannot_restore": "This backup failed its read-back check and can't be restored — pick another backup.",
|
|
||||||
"verified_at": "Verified {{when}}",
|
"verified_at": "Verified {{when}}",
|
||||||
"skipped_entries_one": "{{count}} entry not archived",
|
"skipped_entries_one": "{{count}} entry not archived",
|
||||||
"skipped_entries_other": "{{count}} entries not archived",
|
"skipped_entries_other": "{{count}} entries not archived",
|
||||||
|
|||||||
@@ -47,18 +47,14 @@
|
|||||||
"restore_confirm_yes": "确认恢复",
|
"restore_confirm_yes": "确认恢复",
|
||||||
"restore_confirm_yes_unsafe": "直接覆盖并恢复",
|
"restore_confirm_yes_unsafe": "直接覆盖并恢复",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"restore_started": "已开始恢复备份——正在用此备份重建世界。完成后启动服务器即可看到恢复后的世界。",
|
|
||||||
"restore_snapshot_started": "已开始为当前世界做安全快照——快照完成后会自动用所选备份恢复,进度见下方“最近操作”。恢复完成后启动服务器即可看到恢复后的世界。",
|
|
||||||
"restore_started_short": "已启动恢复",
|
"restore_started_short": "已启动恢复",
|
||||||
"restore_snapshot_started_short": "快照中,随后恢复",
|
"restore_snapshot_started_short": "快照中,随后恢复",
|
||||||
"stopping": "正在停止服务器……",
|
"stopping": "正在停止服务器……",
|
||||||
"restoring": "正在恢复备份……",
|
"restoring": "正在恢复备份……",
|
||||||
"stop_timeout": "服务器停止超时。请关闭此窗口,稍后重试。",
|
"stop_timeout": "服务器停止超时。请关闭此窗口,稍后重试。",
|
||||||
"expired_cannot_restore": "此备份已过期,无法恢复。",
|
|
||||||
"corrupt_badge": "已损坏",
|
"corrupt_badge": "已损坏",
|
||||||
"corrupt_hint": "回读校验发现这份归档与写入时不一致,已无法完整恢复。它会保留到过期,方便排查。",
|
"corrupt_hint": "回读校验发现这份归档与写入时不一致,已无法完整恢复。它会保留到过期,方便排查。",
|
||||||
"corrupt_short": "已损坏",
|
"corrupt_short": "已损坏",
|
||||||
"corrupt_cannot_restore": "这份备份回读校验未通过,无法恢复,请选择另一份备份。",
|
|
||||||
"verified_at": "{{when}}已校验",
|
"verified_at": "{{when}}已校验",
|
||||||
"skipped_entries": "{{count}} 个条目未归档",
|
"skipped_entries": "{{count}} 个条目未归档",
|
||||||
"skipped_entries_hint": "世界目录里有符号链接等非普通文件,归档时跳过了它们,恢复后这些条目不会存在。",
|
"skipped_entries_hint": "世界目录里有符号链接等非普通文件,归档时跳过了它们,恢复后这些条目不会存在。",
|
||||||
|
|||||||
@@ -137,8 +137,6 @@ function BackupRow({
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
onReloadStatus={onReloadStatus}
|
onReloadStatus={onReloadStatus}
|
||||||
buttonVariant={isLatest ? "destructive" : "outline"}
|
buttonVariant={isLatest ? "destructive" : "outline"}
|
||||||
buttonSize="sm"
|
|
||||||
layout="row"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground/40 font-medium px-3 py-1.5">
|
<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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RestoreControls is the restore ACTION on each unexpired backup row. Restore
|
/** RestoreControls is the restore button on each backup row that can still be
|
||||||
* overwrites the world, so it hides behind a single button that opens a confirm
|
* restored (the row renders a note in its place for a corrupt or expired one), and
|
||||||
* dialog. The dialog states the full cost up front — the server is stopped (online
|
* any of them may be picked, not only the newest. Restore overwrites the world, so
|
||||||
* players drop) and the current world is overwritten by THIS exact backup — and
|
* the button opens a confirm dialog that states the full cost up front: the server is
|
||||||
* offers a safety snapshot, on by default: the backend first backs up the world as
|
* stopped (online players drop) and the current world is overwritten by THIS exact
|
||||||
* it is and restores only once that succeeded, which makes a wrong pick undoable.
|
* backup. It offers a safety snapshot, on by default: the backend first backs up the
|
||||||
* Turning it off brings back the irreversible wording. On confirm it runs the whole
|
* world as it is and restores only once that succeeded, which makes a wrong pick
|
||||||
* chain itself: stop → wait for Stopped → restore.
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* terminal: the dialog closes and the row shows a "restore started" note in place of
|
||||||
* re-offering the trigger, so a second restore Job can't race the first. */
|
* the button, so a second restore Job can't race the first. */
|
||||||
const POLL_MS = 2500;
|
const POLL_MS = 2500;
|
||||||
const MAX_POLLS = 24; // ~60s ceiling before we stop waiting for Stopped
|
const MAX_POLLS = 24; // ~60s ceiling before we stop waiting for Stopped
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -293,17 +292,13 @@ function RestoreControls({
|
|||||||
locale,
|
locale,
|
||||||
onReloadStatus,
|
onReloadStatus,
|
||||||
buttonVariant = "destructive",
|
buttonVariant = "destructive",
|
||||||
buttonSize = "sm",
|
|
||||||
layout = "card",
|
|
||||||
}: {
|
}: {
|
||||||
serverName: string;
|
serverName: string;
|
||||||
backup: BackupView;
|
backup: BackupView;
|
||||||
now: number;
|
now: number;
|
||||||
locale: string;
|
locale: string;
|
||||||
onReloadStatus: () => void;
|
onReloadStatus: () => void;
|
||||||
buttonVariant?: "destructive" | "outline" | "ghost" | "default";
|
buttonVariant?: "destructive" | "outline";
|
||||||
buttonSize?: "default" | "sm" | "lg" | "icon";
|
|
||||||
layout?: "card" | "row";
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("backups");
|
const { t } = useTranslation("backups");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -317,26 +312,7 @@ function RestoreControls({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [safety, setSafety] = useState(true);
|
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 (done) {
|
||||||
if (layout === "row") {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-emerald-500 font-medium">
|
<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" />
|
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
@@ -344,13 +320,6 @@ function RestoreControls({
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll until the world volume is provably free. Returns true once Stopped is
|
// Poll until the world volume is provably free. Returns true once Stopped is
|
||||||
// observed, false after the ceiling — restore MUST NOT proceed on a false.
|
// observed, false after the ceiling — restore MUST NOT proceed on a false.
|
||||||
@@ -393,8 +362,8 @@ function RestoreControls({
|
|||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant={buttonVariant} size={buttonSize}>
|
<Button variant={buttonVariant} size="sm">
|
||||||
<RotateCcw className="h-4 w-4" /> {t(layout === "row" ? "restore_btn_short" : "restore_btn")}
|
<RotateCcw className="h-4 w-4" /> {t("restore_btn_short")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
);
|
);
|
||||||
@@ -454,24 +423,7 @@ function RestoreControls({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (layout === "row") {
|
|
||||||
return (
|
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
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(next) => {
|
onOpenChange={(next) => {
|
||||||
@@ -483,13 +435,12 @@ function RestoreControls({
|
|||||||
{trigger}
|
{trigger}
|
||||||
{dialogContent}
|
{dialogContent}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ServerBackups is the per-server backup surface (/servers/:name/backups): view the
|
/** 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
|
* world archives kept for this server and (B2) roll the world back to any of them
|
||||||
* recent one. It owns its own gating — ownership from /me/servers, since GET status
|
* 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
|
* 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
|
* 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. */
|
* fact requires the server to be STOPPED, so this page must work while it is asleep. */
|
||||||
|
|||||||
Reference in new issue
Block a user