Unverified Commit dee4d87f authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 9f60178b
Loading
Loading
Loading
Loading
+78 −5
Changes for panel/dev/mockApi.ts: 78 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -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) {
+0 −109
Changes for panel/src/components/ServerCard.tsx: 0 added lines, 109 removed lines.
Original line number Diff line number Diff line
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>
  );
}
+0 −4
Changes for panel/src/i18n/resources/en-US/backups.json: 0 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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",
+0 −4
Changes for panel/src/i18n/resources/zh-CN/backups.json: 0 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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": "世界目录里有符号链接等非普通文件,归档时跳过了它们,恢复后这些条目不会存在。",
+16 −65
Changes for panel/src/pages/ServerBackups.tsx: 16 added lines, 65 removed lines.
Original line number Diff line number Diff line
@@ -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,26 +312,7 @@ 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" />
@@ -344,13 +320,6 @@ function RestoreControls({
      </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
  // observed, false after the ceiling — restore MUST NOT proceed on a false.
@@ -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,24 +423,7 @@ 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) => {
@@ -483,13 +435,12 @@ function RestoreControls({
      {trigger}
      {dialogContent}
    </Dialog>
    </div>
  );
}

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