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

This commit is contained in:
Lemon-miaow committed 2026-09-27 06:02:13 +08:00
1 parent 9a238780e5
commit 75dd639cca
6 files changed
+135 -40

No files matched your search

+45
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
});
});
+14 -24
View File
@@ -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 ? (