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

feat(panel): add back up now and recent operations to the backups page

The backups page could list and restore archives but not create one,
and nothing surfaced backup/restore Job outcomes — a failed 202 was
visible only through kubectl. Add a Back up now action (enabled only on
a stopped server, the backend's own gate; a raced 409 is surfaced in
its words) and a Recent operations card fed by GET /servers/{name}/jobs
that shows running/succeeded/failed with the Job's failure message,
re-reads on an interval while a Job is running, and persists across
reloads. Wire-shape tests pin both endpoints.
parent 2010961d
Loading
Loading
Loading
Loading
+12 −1
Changes for panel/src/i18n/resources/en-US/backups.json: 12 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -9,11 +9,22 @@
  "reason_inactive": "Idle archive",
  "reason_manual": "Manual backup",
  "reason_label": "Reason: {{reason}}",
  "backup_now": "Back up now",
  "backup_in_progress": "Starting backup…",
  "backup_started": "Backup started — its progress shows under recent operations below.",
  "backup_requires_stopped": "Stop the server before backing it up.",
  "expires_in": "Expires {{when}}",
  "expired": "Expired",
  "former_owner": "Former owner: {{owner}}",
  "empty_title": "No backups for this server",
  "empty_hint": "A backup is saved only when a world is archived after long inactivity.",
  "empty_hint": "A backup is saved when a world is archived after long inactivity — or create one now with “Back up now”.",
  "jobs_title": "Recent operations",
  "jobs_empty": "No recent backup or restore operations.",
  "job_backup": "Backup",
  "job_restore": "Restore",
  "job_running": "Running",
  "job_succeeded": "Succeeded",
  "job_failed": "Failed",
  "restore_btn": "Restore this backup",
  "restore_btn_short": "Restore",
  "restore_confirm": "Restoring a backup will stop the server (all online players will be disconnected) and completely overwrite the current world with this backup. This operation is irreversible. Do you want to continue?",
+12 −1
Changes for panel/src/i18n/resources/zh-CN/backups.json: 12 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -9,11 +9,22 @@
  "reason_inactive": "闲置自动回收",
  "reason_manual": "手动备份",
  "reason_label": "原因:{{reason}}",
  "backup_now": "立即备份",
  "backup_in_progress": "正在启动备份……",
  "backup_started": "已开始备份——进度可在下方“最近操作”中查看。",
  "backup_requires_stopped": "备份前需要先停止服务器。",
  "expires_in": "{{when}}过期",
  "expired": "已过期",
  "former_owner": "原世界所有者:{{owner}}",
  "empty_title": "暂无备份",
  "empty_hint": "仅在服务器长期闲置被系统自动回收时才会生成备份。",
  "empty_hint": "服务器长期闲置被自动回收时会生成备份,也可以点“立即备份”马上创建。",
  "jobs_title": "最近操作",
  "jobs_empty": "暂无备份或恢复操作记录。",
  "job_backup": "备份",
  "job_restore": "恢复",
  "job_running": "进行中",
  "job_succeeded": "成功",
  "job_failed": "失败",
  "restore_btn": "恢复此备份",
  "restore_btn_short": "恢复",
  "restore_confirm": "恢复备份将停止服务器(所有在线玩家将被断开),并用该备份完全覆盖当前世界的全部数据。此操作不可逆,请确认是否继续?",
+32 −0
Changes for panel/src/lib/api.test.ts: 32 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -549,4 +549,36 @@ describe("image whitelist and builds wire shapes", () => {
      expect(JSON.parse((opts as RequestInit).body as string)).toEqual(win);
    });
  });

  describe("backup now and server jobs wire shapes", () => {
    it("backupNow POSTs to /servers/{name}/backup with no body and parses the 202", async () => {
      const fetchSpy = fakeFetch({ name: "survival", status: "backing_up" }, { status: 202 });
      vi.stubGlobal("fetch", fetchSpy);
      const res = await api.backupNow("survival");
      expect(res.status).toBe("backing_up");
      const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
      expect(String(url)).toBe("/servers/survival/backup");
      expect((opts as RequestInit).method).toBe("POST");
      expect((opts as RequestInit).body).toBeUndefined();
    });

    it("serverJobs GETs /servers/{name}/jobs and unwraps the jobs array", async () => {
      const job = {
        name: "felis-backup-survival-123",
        kind: "backup",
        state: "failed",
        message: "pod terminated",
        started_at: "2026-07-03T12:00:00Z",
      };
      const fetchSpy = fakeFetch({ server: "survival", jobs: [job] });
      vi.stubGlobal("fetch", fetchSpy);
      const res = await api.serverJobs("survival");
      expect(res).toHaveLength(1);
      expect(res[0].state).toBe("failed");
      expect(res[0].message).toBe("pod terminated");
      const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
      expect(String(url)).toBe("/servers/survival/jobs");
      expect((opts as RequestInit).method).toBe("GET");
    });
  });
});
+20 −0
Changes for panel/src/lib/api.ts: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@ import type {
  PlayersResult,
  QuotaInput,
  QuotaView,
  ServerJob,
  ServerInfo,
  SessionView,
  UserDetail,
@@ -339,6 +340,25 @@ export const api = {
      backupId ? { backup_id: backupId } : undefined,
    ),

  // backupNow enqueues a manual backup (spec §7 POST backup). Preconditions are
  // enforced server-side and surfaced as codes: owner-or-admin (403) and the
  // server MUST be fully stopped (409 not_stopped — the world volume is RWO), so
  // callers gate the action on phase === "Stopped". The reply is 202
  // {name, status:"backing_up"}: the Job is enqueued, not done — watch
  // serverJobs for the outcome.
  backupNow: (name: string) =>
    request<{ name: string; status: string }>("POST", `/servers/${name}/backup`),

  // serverJobs lists the newest backup/restore Jobs of one server, newest first
  // (GET /servers/{name}/jobs). Owner-or-admin gated server-side; a Job's
  // failure text rides `message`. The backend answers 503 until the job-status
  // reader is wired, so callers should tolerate that error.
  serverJobs: (name: string) =>
    request<{ server: string; jobs: ServerJob[] }>(
      "GET",
      `/servers/${name}/jobs`,
    ).then((r) => r.jobs ?? []),

  // Account linking (spec §10). Both are POST: start reports status from the
  // session principal (no body, side-effect-free), verify consumes a code the
  // player was shown in-game. The panel can never mint a code — that is the
+12 −0
Changes for panel/src/lib/types.ts: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -146,6 +146,18 @@ export interface BackupView {
  expires_at: string;
}

/** ServerJob is one row of GET /api/v1/servers/{name}/jobs — the observable
 *  outcome of an async backup/restore Job. The API only enqueues Jobs, so this
 *  projection is how a 202 that later failed becomes visible in the panel. */
export interface ServerJob {
  name: string;
  kind: string; // "backup" | "restore"
  state: string; // "running" | "succeeded" | "failed"
  message?: string;
  started_at?: string;
  finished_at?: string;
}

/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
export interface WhitelistImage {
  image_ref: string;
Loading