chore(panel): 删除未使用的 ServerCard 与恢复控件的卡片布局分支,恢复注释改为按行可选任一未过期备份,mock 补上备份任务与立即备份路由
This commit is contained in:
5 files changed
+108
-201
No files matched your search
+78
-5
@@ -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) {
|
||||
|
||||
@@ -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": "世界目录里有符号链接等非普通文件,归档时跳过了它们,恢复后这些条目不会存在。",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user