Skip to content
Open
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 assets/design-export/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -2874,3 +2874,6 @@ Nodes now follows the Backups toolbar: a plain search input, capped at 320px on

All seven roots have JSON from Pencil `Get` and 2× PNG from `Export`. Toolbar, popover, desktop, fixed-cluster and mobile layouts were inspected using `TakeScreenshot`; the full screens retain their previous dimensions and content. JSON exports retain component references without elided children or editing placeholders. No React source, tests or lint suites were changed or run for this design update.

## Stop during provisioning

The Pending server Overview (`o4LH8W`) shows Stop enabled in the header and in the lifecycle Quick actions card. Restart remains disabled until the server is running.
595 changes: 86 additions & 509 deletions assets/design-export/json/o4LH8W.json

Large diffs are not rendered by default.

Binary file modified assets/design-export/screenshots/o4LH8W.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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.
597 changes: 87 additions & 510 deletions design.pen

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions web/e2e/screenshots/slice2a.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ test.describe("Slice 2a: Servers + core tabs (Desktop — 1440x900) @screenshots
await expect(page.getByText(/waiting on storage/i)).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/PVCProvisioningFailed/)).toBeVisible();
await page.waitForTimeout(200);
const stopButtons = page.getByRole("button", { name: "Stop", exact: true });
await expect(stopButtons).toHaveCount(2);
await expect(stopButtons.first()).toBeEnabled();
await expect(stopButtons.last()).toBeEnabled();
const restartButtons = page.getByRole("button", { name: "Restart", exact: true });
await expect(restartButtons).toHaveCount(2);
await expect(restartButtons.first()).toBeDisabled();
await expect(restartButtons.last()).toBeDisabled();
await capture(page, "o4LH8W");
});

Expand Down
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
10 changes: 9 additions & 1 deletion web/src/test/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,12 +913,20 @@ export function buildScreenshotHandlers() {
items: data.templates,
}),
),
http.get("/templates/:name", ({ params }) => {
http.get("/templates/:name", ({ params, cookies }) => {
const name = String(params.name);
const template = data.templates.find((t) => t.metadata.name === name);
// Only the single-get carries a configSchema: Settings > Game
// configuration reads it, the wizard's list view must stay unchanged.
const configSchema = data.templateConfigSchemas[name];
// This Pending server uses the lifecycle actions shown for templates
// without in-game commands. Other Minecraft examples keep their commands.
if (template && name === "minecraft-java" && cookies.e2e_server_variant === "pvc-failed") {
return HttpResponse.json({
...template,
spec: { ...template.spec, configSchema, capabilities: { ...template.spec.capabilities, actions: [] } },
});
}
if (template && configSchema) {
return HttpResponse.json({ ...template, spec: { ...template.spec, configSchema } });
}
Expand Down
Loading