Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/unreleased/932-stop-during-startup.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions web/specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions web/src/components/server/ServerActionsCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ServerActionsCard name="s1" ns="team-b" tmpl={tmpl([])} gs={gs} />);
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 () => {
Expand Down
3 changes: 2 additions & 1 deletion web/src/components/server/ServerActionsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Card className="border border-border bg-card p-0">
Expand All @@ -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 && (
<LifecycleButton
Expand Down
19 changes: 15 additions & 4 deletions web/src/routes/ServerDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,25 @@ describe("ServerDetailPage lifecycle buttons", () => {
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(<ServerDetailPage />);
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 () => {
Expand Down
9 changes: 2 additions & 7 deletions web/src/routes/ServerDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}
>
<Square className="h-4 w-4" /> {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" })}</Button>
{asleep && (
Expand Down
9 changes: 9 additions & 0 deletions web/src/routes/Servers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ServersPage />);
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 () => {
Expand Down
Loading