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

-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. */