From b74830966f7a5a396189c4a8f9dd18be710a381a Mon Sep 17 00:00:00 2001 From: Baylem Date: Thu, 12 Nov 2026 00:02:52 -0500 Subject: [PATCH] Allow operators to stop a provisioning server from its detail page Signed-off-by: Baylem --- .../unreleased/932-stop-during-startup.md | 3 +++ web/specs.md | 1 + .../server/ServerActionsCard.test.tsx | 11 +++++++++++ .../components/server/ServerActionsCard.tsx | 3 ++- web/src/routes/ServerDetail.test.tsx | 19 +++++++++++++++---- web/src/routes/ServerDetail.tsx | 9 ++------- web/src/routes/Servers.test.tsx | 9 +++++++++ 7 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 changelog/unreleased/932-stop-during-startup.md diff --git a/changelog/unreleased/932-stop-during-startup.md b/changelog/unreleased/932-stop-during-startup.md new file mode 100644 index 000000000..01e8b9a2a --- /dev/null +++ b/changelog/unreleased/932-stop-during-startup.md @@ -0,0 +1,3 @@ +# Stop a provisioning server + +The server header and Overview now let operators stop a server while it is pending or starting. diff --git a/web/specs.md b/web/specs.md index 6f8520031..3602750e9 100644 --- a/web/specs.md +++ b/web/specs.md @@ -246,6 +246,7 @@ The old Radix-based `ui/` components that shipped before this rebuild — `butto 2. **ServerDetail Page** (`web/src/routes/ServerDetail.tsx`, 289 lines) - Full server view with header showing phase chip, uptime, player count, and action buttons + - Both the header and Overview offer Stop during Pending and Starting to users with control access to that server. - Tabbed interface (HeroUI Tabs) routing to nine sub-views: Overview, Events, Console, Logs, Files, Mods, Modpacks, Players, Backups, Capture, Settings - Phase chip from ui/PhaseChip; game icon from ui/GameIcon - Lifecycle action buttons (start/stop/restart) gated by phase state diff --git a/web/src/components/server/ServerActionsCard.test.tsx b/web/src/components/server/ServerActionsCard.test.tsx index d6f63f8ba..f2f36630e 100644 --- a/web/src/components/server/ServerActionsCard.test.tsx +++ b/web/src/components/server/ServerActionsCard.test.tsx @@ -1000,6 +1000,17 @@ describe("ServerActionsCard", () => { expect(onOpenConsole).toHaveBeenCalled(); }); + it.each(["Pending", "Starting"] as const)("can stop a provisioning server while %s", async (phase) => { + routeFetch("operator", []); + const gs: GameServer = { metadata: { name: "s1" }, spec: { templateRef: { name: "minecraft-java" } }, status: { phase } }; + renderWithQuery(); + const stop = await screen.findByRole("button", { name: /^Stop$/i }); + expect(stop).toBeEnabled(); + expect(screen.getByRole("button", { name: /^Restart$/i })).toBeDisabled(); + fireEvent.click(stop); + await waitFor(() => expect(fetchMock.mock.calls.filter(([url]) => String(url).includes("/servers/s1:stop?namespace=team-b"))).toHaveLength(1)); + }); + // F-126-adjacent: the generic lifecycle shortcuts' mutation had no error // handler — a failed Restart/Stop/Start/Wake left "Quick actions" silent. it("shows an error banner when a generic lifecycle shortcut fails", async () => { diff --git a/web/src/components/server/ServerActionsCard.tsx b/web/src/components/server/ServerActionsCard.tsx index 7a04be753..847c56109 100644 --- a/web/src/components/server/ServerActionsCard.tsx +++ b/web/src/components/server/ServerActionsCard.tsx @@ -211,6 +211,7 @@ export function ServerActionsCard({ // "not asleep yet" (players may be connected during the drain window). const asleep = gs.status?.idle?.asleep === true && phase !== "Stopping"; const running = phase === "Running"; + const canStop = running || asleep || phase === "Starting" || phase === "Pending"; const canStart = (phase === "Stopped" || phase === "Suspended" || phase === "Failed") && !asleep; return ( @@ -233,7 +234,7 @@ export function ServerActionsCard({ icon={Square} label={intl.formatMessage({ id: "web.components.server.server_actions_card.stop", defaultMessage: "Stop", description: "Server actions card: button that stops the running server; keep it short" })} onClick={() => lifecycle.mutate("stop")} - disabled={!canRun || (!running && !asleep) || lifecycle.isPending} + disabled={!canRun || !canStop || lifecycle.isPending} /> {asleep && ( { expect(screen.getByRole("button", { name: /^Restart$/i })).toBeDisabled(); }); - it("hides Start during a transitional phase (Starting)", async () => { + it.each(["Pending", "Starting"] as const)("can stop a remote server while %s", async (phase) => { + searchParams = { cluster: "remote", ns: "team-b" }; + const stopRequests: URL[] = []; server.use( - http.get("/servers/alpha", () => HttpResponse.json(makeServer({ status: { phase: "Starting" } }))), + http.get("/servers/alpha", () => HttpResponse.json(makeServer({ metadata: { namespace: "team-b" }, status: { phase } }))), + http.post(/\/servers\/alpha:stop$/, ({ request }) => { + stopRequests.push(new URL(request.url)); + return new HttpResponse(null, { status: 202 }); + }), ); renderWithQuery(); - await screen.findByRole("button", { name: /^Restart$/i }); + const stop = await screen.findByRole("button", { name: /^Stop$/i }); + await waitFor(() => expect(stop).toBeEnabled()); expect(screen.queryByRole("button", { name: /^Start$/i })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: /^Stop$/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /^Restart$/i })).toBeDisabled(); + await userEvent.click(stop); + await waitFor(() => expect(stopRequests).toHaveLength(1)); + expect(stopRequests[0]?.searchParams.get("namespace")).toBe("team-b"); + expect(stopRequests[0]?.searchParams.get("cluster")).toBe("remote"); }); it("when asleep: Wake button shown, Start hidden", async () => { diff --git a/web/src/routes/ServerDetail.tsx b/web/src/routes/ServerDetail.tsx index 4ed7961fd..af6835c54 100644 --- a/web/src/routes/ServerDetail.tsx +++ b/web/src/routes/ServerDetail.tsx @@ -182,12 +182,7 @@ function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) { gs?.status?.conditions?.find((c) => c.type === "Ready"), gs?.status?.conditionArgs, ); - // Gate lifecycle actions on phase so they aren't fired during a - // transition (Starting/Stopping/Pending): Start only from a stopped - // state, Stop/Restart only while Running. act.isPending blocks - // duplicate mutations from a double-click. When asleep, Start is a silent - // no-op (spec.suspend is already false) — only :wake clears the operator's - // sleep marker. + // An idle server must wake through :wake to clear its sleep marker. const canStart = (phase === "Stopped" || phase === "Suspended" || phase === "Failed") && !asleep; const version = gs?.status?.agent?.gameVersion; const uptime = formatUptime(gs?.status?.startedAt, intl); @@ -293,7 +288,7 @@ function ServerDetailView({ lookupTarget }: { lookupTarget: ResourceTarget }) { variant="outline" className="rounded-full" onPress={() => act.mutate("stop")} - isDisabled={!access?.canControl || (!running && !asleep) || act.isPending} + isDisabled={!access?.canControl || (!running && !asleep && !provisioning) || act.isPending} > {intl.formatMessage({ id: "web.routes.server_detail.stop", defaultMessage: "Stop", description: "Server detail: header button that stops the running server, or puts a sleeping server to sleep fully; keep it short" })} {asleep && ( diff --git a/web/src/routes/Servers.test.tsx b/web/src/routes/Servers.test.tsx index c01085fa5..6965a7ffc 100644 --- a/web/src/routes/Servers.test.tsx +++ b/web/src/routes/Servers.test.tsx @@ -746,6 +746,15 @@ describe("ServersPage", () => { }, 10_000); }); + it.each(["Pending", "Starting"] as const)("allows stopping a %s server from its fleet row", async (phase) => { + server.use(http.get("/servers", () => HttpResponse.json({ + items: [makeServer({ metadata: { name: "booting", namespace: "gameplane-games" }, status: { phase } })], + }))); + renderWithQuery(); + const row = (await screen.findByText("booting")).closest("tr") as HTMLElement; + expect(within(row).getByTitle("Stop")).toBeEnabled(); + }); + // C1: an asleep server is phase Suspended, but :stop is still a real // action (it patches spec.suspend=true) — Stop must not be dead here. it("keeps Stop enabled for an asleep server", async () => {