diff --git a/panel/src/components/PowerButton.test.tsx b/panel/src/components/PowerButton.test.tsx
index 44d9fda..82e7052 100644
--- a/panel/src/components/PowerButton.test.tsx
+++ b/panel/src/components/PowerButton.test.tsx
@@ -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(
+ ,
+ );
+ 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();
+
+ 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();
+ });
+});
diff --git a/panel/src/components/PowerButton.tsx b/panel/src/components/PowerButton.tsx
index eb25ead..b0c0446 100644
--- a/panel/src/components/PowerButton.tsx
+++ b/panel/src/components/PowerButton.tsx
@@ -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 = ;
} else if (submitted !== null) {
control = inProgress(submitted);
+ } else if (failed && stopOnly) {
+ control = stopButton("outline", () => void run("stop"));
} else if (failed) {
control = (
@@ -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 = (