fix(files): 文件页停服改用控制台同一个按钮,有玩家在线或人数读不到时先确认
This commit is contained in:
6 files changed
+135
-40
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "二进制文件——以只读方式打开;通过编辑器保存非文本字节可能损坏该文件。",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PhaseBadge, shownPhase } from "@/components/PhaseBadge";
|
||||
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
|
||||
import { PowerButton } from "@/components/PowerButton";
|
||||
import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
@@ -156,7 +157,6 @@ export function ServerFiles() {
|
||||
const [opening, setOpening] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
// Closing an editor with unsaved text (Esc, the overlay, ✕, Cancel) asks
|
||||
// first; leaving the page asks through the browser.
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||
@@ -247,20 +247,6 @@ export function ServerFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (stopping) return;
|
||||
setStopping(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.stop(name);
|
||||
statusQ.reload();
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: humanizeError(e) });
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
|
||||
const back = <BackLink to={`/servers/${name}`} label={t("back_to_console")} />;
|
||||
if (statusQ.loading && !statusQ.data) {
|
||||
return (
|
||||
@@ -326,14 +312,18 @@ export function ServerFiles() {
|
||||
{t("stopped_required_body")}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleStop} disabled={stopping}>
|
||||
{stopping ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
{t("stop_server")}
|
||||
</Button>
|
||||
<PowerButton
|
||||
name={name}
|
||||
phase={statusQ.data.phase}
|
||||
desiredState={statusQ.data.desiredState}
|
||||
failed={startFailure(statusQ.data) !== null}
|
||||
playersOnline={statusQ.data.playersOnline}
|
||||
playerCountUnknown={statusQ.data.playerCountUnknown}
|
||||
retiring={statusQ.data.retiring}
|
||||
stopOnly
|
||||
onChanged={statusQ.reload}
|
||||
className="items-center"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : listLoading && entries === null ? (
|
||||
|
||||
Reference in new issue
Block a user