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 () => {