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

fix(files): 文件页停服改用控制台同一个按钮,有玩家在线或人数读不到时先确认

parent 9a238780
Loading
Loading
Loading
Loading
+45 −0
Changes for panel/src/components/PowerButton.test.tsx: 45 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -273,3 +273,48 @@ describe("PowerButton on a server given up or being deleted", () => {
    expect(screen.getByText(t("servers:retiring_badge_delete"))).toBeTruthy();
  });
});

describe("PowerButton on a page that needs the server down", () => {
  const cases: {
    phase: Phase;
    desiredState?: "Running" | "Stopped";
    failed?: boolean;
    shows: string;
    disabled: boolean;
  }[] = [
    { phase: "Running", desiredState: "Running", shows: "servers:stop", disabled: false },
    // A failed start gets Stop alone: a retry would start what the page waits to see down.
    { phase: "Failed", desiredState: "Running", failed: true, shows: "servers:stop", disabled: false },
    // Woken while going down: Stop keeps it down.
    { phase: "Stopping", desiredState: "Running", shows: "servers:stop", disabled: false },
    // A phase nobody can place offers Stop, never a wake.
    { phase: "Unknown", desiredState: "Stopped", shows: "servers:stop", disabled: false },
    { phase: "Unknown", shows: "servers:stop", disabled: false },
    { phase: "Failed", desiredState: "Stopped", shows: "servers:stop", disabled: false },
    // Already on its way down: nothing to press.
    { phase: "Stopping", desiredState: "Stopped", shows: "servers:stopping", disabled: true },
    { phase: "Running", desiredState: "Stopped", shows: "servers:stopping", disabled: true },
  ];

  for (const c of cases) {
    it(`${c.phase} asked ${c.desiredState ?? "(unknown)"}${c.failed ? " (failed)" : ""} offers ${c.shows}`, () => {
      render(
        <PowerButton name="lobby" phase={c.phase} desiredState={c.desiredState} failed={c.failed} stopOnly onChanged={vi.fn()} />,
      );
      const buttons = screen.getAllByRole("button");
      expect(buttons).toHaveLength(1);
      expect(buttons[0].textContent).toBe(t(c.shows));
      expect(buttons[0]).toHaveProperty("disabled", c.disabled);
    });
  }

  it("still asks before disconnecting players", async () => {
    stop.mockResolvedValue(undefined);
    render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={2} stopOnly onChanged={vi.fn()} />);

    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));

    expect(stop).not.toHaveBeenCalled();
    expect(screen.getByText(t("servers:stop_confirm_players", { count: 2 }))).toBeTruthy();
  });
});
+11 −2
Changes for panel/src/components/PowerButton.tsx: 11 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -29,6 +29,10 @@ interface Props {
  /** A pending retirement: nothing starts the server until it is cancelled, so
   *  the control gives way to a badge saying why. */
  retiring?: RetireState;
  /** Offer only ways down: a page that needs the server stopped (the file
   *  editor) never offers a wake or a retry, and a server it cannot place gets
   *  a Stop too. */
  stopOnly?: boolean;
  /** Called after the wake or stop was accepted, so the parent refetches. */
  onChanged: () => void;
  size?: "sm" | "default";
@@ -54,6 +58,8 @@ function isUp(phase: Phase): boolean {
// one asked to run with no pod yet offers Stop, which is the way out when it
// never comes up.
// A server given up or being deleted offers nothing to press, only why.
// The file editor passes stopOnly: it needs the server down, so it offers Stop
// alone, with the same question before disconnecting players.
export function PowerButton({
  name,
  phase,
@@ -62,6 +68,7 @@ export function PowerButton({
  playersOnline,
  playerCountUnknown,
  retiring,
  stopOnly = false,
  onChanged,
  size = "sm",
  className,
@@ -135,6 +142,8 @@ export function PowerButton({
    control = <RetiringBadge retiring={retiring} />;
  } else if (submitted !== null) {
    control = inProgress(submitted);
  } else if (failed && stopOnly) {
    control = stopButton("outline", () => void run("stop"));
  } else if (failed) {
    control = (
      <div className="flex flex-wrap items-center justify-end gap-2">
@@ -147,10 +156,10 @@ export function PowerButton({
    );
  } else if (pending === "stop" || (phase === "Stopping" && pending === null)) {
    control = inProgress("stop");
  } else if (pending === "start" && phase === "Stopping") {
  } else if (!stopOnly && pending === "start" && phase === "Stopping") {
    // Woken while stopping: the operator brings it back up once it is down.
    control = inProgress("wake");
  } else if (!on) {
  } else if (!stopOnly && !on) {
    control = (
      <Button size={size} onClick={() => void run("wake")} disabled={busy !== null}>
        {busy === "wake" ? spinner : <Play />}
+1 −2
Changes for panel/src/i18n/resources/en-US/files.json: 1 added line, 2 removed lines.
Original line number Diff line number Diff line
@@ -10,9 +10,8 @@
  "empty_dir_title": "This folder is empty",
  "empty_dir_hint": "Nothing has been written here yet.",
  "list_truncated": "The listing was truncated at the server's safety limit.",
  "stopped_required_title": "The server is running",
  "stopped_required_title": "Stop the server first",
  "stopped_required_body": "Files can only be browsed or edited while the server is fully stopped — the world volume is single-attached to the running server. Stop it to continue.",
  "stop_server": "Stop server",
  "not_yours_title": "Not your server",
  "not_yours_body": "Only the owner or an admin can edit this server's files.",
  "binary_hint": "Binary file — opened read-only; saving non-text bytes through an editor could corrupt it.",
+1 −2
Changes for panel/src/i18n/resources/zh-CN/files.json: 1 added line, 2 removed lines.
Original line number Diff line number Diff line
@@ -10,9 +10,8 @@
  "empty_dir_title": "此目录为空",
  "empty_dir_hint": "这里还没有写入任何内容。",
  "list_truncated": "列表已按服务器安全上限截断。",
  "stopped_required_title": "服务器正在运行",
  "stopped_required_title": "需要先停服",
  "stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。",
  "stop_server": "停止服务器",
  "not_yours_title": "这不是你的服务器",
  "not_yours_body": "只有服务器所有者或管理员才能编辑其文件。",
  "binary_hint": "二进制文件——以只读方式打开;通过编辑器保存非文本字节可能损坏该文件。",
+63 −10
Changes for panel/src/pages/ServerFiles.test.tsx: 63 added lines, 10 removed lines.
Original line number Diff line number Diff line
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { ServerFiles } from "./ServerFiles";
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";

const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn(), status: vi.fn() }));
const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn(), status: vi.fn(), stop: vi.fn() }));

vi.mock("@/lib/api", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/api")>();
@@ -16,6 +16,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
    api: {
      ...actual.api,
      status: mocks.status,
      stop: mocks.stop,
      listServerFiles: () =>
        Promise.resolve({
          path: "",
@@ -31,7 +32,7 @@ vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }

const t = (key: string) => i18next.t(key);

async function openEditor() {
function renderFiles() {
  render(
    <MemoryRouter initialEntries={["/servers/lobby/files"]}>
      <Routes>
@@ -39,6 +40,10 @@ async function openEditor() {
      </Routes>
    </MemoryRouter>,
  );
}

async function openEditor() {
  renderFiles();
  await userEvent.click(await screen.findByText("server.properties"));
  const dialog = await screen.findByRole("dialog");
  return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
@@ -48,6 +53,7 @@ const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredSt

beforeEach(() => {
  mocks.writeServerFile.mockReset();
  mocks.stop.mockReset();
  mocks.status.mockReset();
  mocks.status.mockResolvedValue(stopped);
});
@@ -106,13 +112,7 @@ describe("ServerFiles on a running server", () => {
  it("lists the files once the server has stopped, without a reload", async () => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
    mocks.status.mockResolvedValue({ ...stopped, phase: "Running", desiredState: "Running", ready: true });
    render(
      <MemoryRouter initialEntries={["/servers/lobby/files"]}>
        <Routes>
          <Route path="/servers/:name/files" element={<ServerFiles />} />
        </Routes>
      </MemoryRouter>,
    );
    renderFiles();
    expect(await screen.findByText(t("files:stopped_required_title"))).toBeTruthy();
    expect(screen.queryByText("server.properties")).toBeNull();

@@ -126,4 +126,57 @@ describe("ServerFiles on a running server", () => {
    await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS * 3));
    expect(mocks.status.mock.calls.length).toBe(reads);
  });

  it("asks before stopping a server with players on it", async () => {
    mocks.stop.mockResolvedValue(undefined);
    mocks.status.mockResolvedValue({ ...stopped, phase: "Running", desiredState: "Running", ready: true, playersOnline: 2 });
    renderFiles();

    await userEvent.click(await screen.findByRole("button", { name: t("servers:stop") }));
    expect(mocks.stop).not.toHaveBeenCalled();
    expect(screen.getByText(i18next.t("servers:stop_confirm_players", { count: 2 }))).toBeTruthy();

    const reads = mocks.status.mock.calls.length;
    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
    expect(mocks.stop).toHaveBeenCalledWith("lobby");
    // The page rereads at once to follow the stop.
    await waitFor(() => expect(mocks.status.mock.calls.length).toBeGreaterThan(reads));
  });

  it("asks when the player count cannot be read", async () => {
    mocks.status.mockResolvedValue({
      ...stopped,
      phase: "Running",
      desiredState: "Running",
      ready: true,
      playersOnline: 0,
      playerCountUnknown: true,
    });
    renderFiles();

    await userEvent.click(await screen.findByRole("button", { name: t("servers:stop") }));

    expect(mocks.stop).not.toHaveBeenCalled();
    expect(screen.getByText(t("servers:stop_confirm_unknown"))).toBeTruthy();
  });

  it("offers a failed start a stop, sent without asking, and nothing that would start it", async () => {
    mocks.stop.mockResolvedValue(undefined);
    // Nobody is on a server that never came up, so even an unreadable count does not ask.
    mocks.status.mockResolvedValue({
      ...stopped,
      phase: "Failed",
      desiredState: "Running",
      ready: false,
      playersOnline: 0,
      playerCountUnknown: true,
    });
    renderFiles();

    await screen.findByText(t("files:stopped_required_title"));
    expect(screen.queryByRole("button", { name: t("servers:retry_start") })).toBeNull();
    await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));

    expect(mocks.stop).toHaveBeenCalledWith("lobby");
  });
});
Loading