Loading panel/src/components/PhaseBadge.test.ts +32 −1 Changes for panel/src/components/PhaseBadge.test.ts: 32 added lines, 1 removed line. Original line number Diff line number Diff line import { describe, it, expect } from "vitest"; import { PHASE_COLOR, phaseColor, phaseVariant, startFailure } from "@/components/PhaseBadge"; import { PHASE_COLOR, pendingPower, phaseColor, phaseVariant, shownPhase, startFailure } from "@/components/PhaseBadge"; import type { Phase } from "@/lib/types"; // The closed lifecycle set felis-api can emit — the Go source of truth is Loading Loading @@ -59,3 +59,34 @@ describe("start failure", () => { expect(startFailure({ phase: "Failed", desiredState: "Running", startGaveUp: true })).toBe("gaveUp"); }); }); describe("a server asked to move", () => { // desiredState flips the moment a wake or stop is accepted; the phase follows // once the operator acts. Every pair the two can be in: const cases: [Phase, "Running" | "Stopped" | undefined, "start" | "stop" | null, Phase][] = [ ["Stopped", "Running", "start", "Starting"], ["Unknown", "Running", "start", "Starting"], // Woken while going down: it comes back up once it is down. ["Stopping", "Running", "start", "Starting"], ["Starting", "Running", null, "Starting"], ["Running", "Running", null, "Running"], // A failed start keeps its own badge and retry. ["Failed", "Running", null, "Failed"], ["Running", "Stopped", "stop", "Stopping"], ["Starting", "Stopped", "stop", "Stopping"], ["Stopping", "Stopped", null, "Stopping"], ["Stopped", "Stopped", null, "Stopped"], ["Failed", "Stopped", null, "Failed"], ["Unknown", "Stopped", null, "Unknown"], // A view without desiredState shows the phase as it is. ["Stopped", undefined, null, "Stopped"], ["Running", undefined, null, "Running"], ]; for (const [phase, desiredState, pending, shown] of cases) { it(`${phase} asked ${desiredState ?? "(unknown)"} is pending ${pending} and shows ${shown}`, () => { expect(pendingPower({ phase, desiredState })).toBe(pending); expect(shownPhase({ phase, desiredState })).toBe(shown); }); } }); panel/src/components/PhaseBadge.tsx +23 −0 Changes for panel/src/components/PhaseBadge.tsx: 23 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -72,6 +72,29 @@ export function startFailure(s: { return s.startGaveUp ? "gaveUp" : "retrying"; } /** PendingPower is a server asked to move that has not got there yet: asked to * run with no pod up ("start"), or asked to stop while still up ("stop"). */ export type PendingPower = "start" | "stop"; /** pendingPower compares the phase with desiredState, the lever a wake or stop * sets at once; the phase follows once the operator acts. Without desiredState * (a public view) nothing reads as pending. */ export function pendingPower(s: { phase: Phase; desiredState?: string }): PendingPower | null { if (s.desiredState === "Running" && (s.phase === "Stopped" || s.phase === "Stopping" || s.phase === "Unknown")) { return "start"; } if (s.desiredState === "Stopped" && (s.phase === "Running" || s.phase === "Starting")) return "stop"; return null; } /** shownPhase is the phase a person should see: one already asked to start reads * Starting, one asked to stop reads Stopping, so a wake or stop shows at once * instead of the old phase lingering until the operator gets to it. */ export function shownPhase(s: { phase: Phase; desiredState?: string }): Phase { const pending = pendingPower(s); return pending === "start" ? "Starting" : pending === "stop" ? "Stopping" : s.phase; } export function PhaseBadge({ phase, failure = null, Loading panel/src/components/PowerButton.test.tsx +134 −12 Changes for panel/src/components/PowerButton.test.tsx: 134 added lines, 12 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import i18next from "i18next"; import { PowerButton } from "./PowerButton"; import type { Phase } from "@/lib/types"; import { PowerButton, SUBMITTED_HOLD_MS, SUBMITTED_RECHECK_MS } from "./PowerButton"; const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() })); vi.mock("@/lib/api", async (importOriginal) => { Loading @@ -18,11 +19,16 @@ beforeEach(() => { stop.mockReset(); }); // A fake-timer test that fails must not leave the clock frozen for the rest. afterEach(() => { vi.useRealTimers(); }); describe("PowerButton", () => { it("wakes a stopped server and tells the parent", async () => { wake.mockResolvedValue(undefined); const onChanged = vi.fn(); render(<PowerButton name="lobby" live={false} onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:wake") })); Loading @@ -33,7 +39,7 @@ describe("PowerButton", () => { it("stays on the page with the reason when the wake is refused", async () => { wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" }); const onChanged = vi.fn(); render(<PowerButton name="lobby" live={false} onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:wake") })); Loading @@ -45,7 +51,7 @@ describe("PowerButton", () => { it("stops an empty server without asking", async () => { stop.mockResolvedValue(undefined); const onChanged = vi.fn(); render(<PowerButton name="lobby" live playersOnline={0} onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); Loading @@ -54,7 +60,7 @@ describe("PowerButton", () => { }); it("asks before disconnecting players, and cancel sends nothing", async () => { render(<PowerButton name="lobby" live playersOnline={3} onChanged={vi.fn()} />); render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={3} onChanged={vi.fn()} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy(); Loading @@ -67,7 +73,7 @@ describe("PowerButton", () => { it("asks when the player count cannot be read", async () => { stop.mockResolvedValue(undefined); const onChanged = vi.fn(); render(<PowerButton name="lobby" live playersOnline={0} playerCountUnknown onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} playerCountUnknown onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(stop).not.toHaveBeenCalled(); Loading @@ -81,7 +87,7 @@ describe("PowerButton", () => { it("keeps the confirmation open with the reason when the stop fails", async () => { stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" }); const onChanged = vi.fn(); render(<PowerButton name="lobby" live playersOnline={2} onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={2} onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); Loading @@ -94,7 +100,7 @@ describe("PowerButton", () => { it("sends one call however fast it is clicked", async () => { let resolve!: () => void; wake.mockReturnValue(new Promise<void>((r) => (resolve = r))); render(<PowerButton name="lobby" live={false} onChanged={vi.fn()} />); render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); const button = screen.getByRole("button", { name: t("servers:wake") }); await userEvent.click(button); Loading @@ -107,7 +113,7 @@ describe("PowerButton", () => { it("offers a failed server a retry, which is the wake", async () => { wake.mockResolvedValue(undefined); const onChanged = vi.fn(); render(<PowerButton name="lobby" live={false} failed onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={onChanged} />); expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull(); await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") })); Loading @@ -121,7 +127,7 @@ describe("PowerButton", () => { let resolve!: () => void; stop.mockReturnValue(new Promise<void>((r) => (resolve = r))); const onChanged = vi.fn(); render(<PowerButton name="lobby" live={false} failed playerCountUnknown onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed playerCountUnknown onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); Loading @@ -133,3 +139,119 @@ describe("PowerButton", () => { await vi.waitFor(() => expect(onChanged).toHaveBeenCalledOnce()); }); }); describe("PowerButton after an accepted call", () => { const ok = () => wake.mockResolvedValue(undefined); it("keeps the wake in progress until the view shows it, then offers the stop", async () => { ok(); const { rerender } = render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); await userEvent.click(screen.getByRole("button", { name: t("servers:wake") })); // The parent's first reread can still carry the old status. rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true); expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull(); rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Running" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:stop") })).toHaveProperty("disabled", false); }); it("asks the parent to reread until the view catches up, and no longer after", async () => { vi.useFakeTimers(); ok(); const onChanged = vi.fn(); const { rerender } = render( <PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />, ); await act(async () => { fireEvent.click(screen.getByRole("button", { name: t("servers:wake") })); }); const afterClick = onChanged.mock.calls.length; await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_RECHECK_MS * 2)); expect(onChanged.mock.calls.length).toBe(afterClick + 2); rerender(<PowerButton name="lobby" phase="Starting" desiredState="Running" onChanged={onChanged} />); const caughtUp = onChanged.mock.calls.length; await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_RECHECK_MS * 3)); expect(onChanged.mock.calls.length).toBe(caughtUp); }); it("gives the button back when the view never catches up", async () => { vi.useFakeTimers(); ok(); render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); await act(async () => { fireEvent.click(screen.getByRole("button", { name: t("servers:wake") })); }); expect(screen.getByRole("button", { name: t("servers:waking") })).toBeTruthy(); await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_HOLD_MS - 1)); expect(screen.getByRole("button", { name: t("servers:waking") })).toBeTruthy(); await act(() => vi.advanceTimersByTimeAsync(1)); expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false); }); it("keeps a stop in progress until the server is down", async () => { stop.mockResolvedValue(undefined); const { rerender } = render( <PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} onChanged={vi.fn()} />, ); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true); rerender(<PowerButton name="lobby" phase="Running" desiredState="Stopped" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true); rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false); }); it("keeps a retry in progress while the view still shows the failure", async () => { ok(); const { rerender } = render( <PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={vi.fn()} />, ); await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") })); rerender(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true); expect(screen.queryByRole("button", { name: t("servers:retry_start") })).toBeNull(); rerender(<PowerButton name="lobby" phase="Starting" desiredState="Running" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:stop") })).toBeTruthy(); }); }); describe("PowerButton on a server already moving", () => { const cases: { phase: Phase; desiredState?: "Running" | "Stopped"; shows: string; disabled: boolean; }[] = [ { phase: "Stopping", shows: "servers:stopping", disabled: true }, { phase: "Stopping", desiredState: "Stopped", shows: "servers:stopping", disabled: true }, { phase: "Running", desiredState: "Stopped", shows: "servers:stopping", disabled: true }, { phase: "Starting", desiredState: "Stopped", shows: "servers:stopping", disabled: true }, // Woken while going down: it comes back up once it is down. { phase: "Stopping", desiredState: "Running", shows: "servers:waking", disabled: true }, // Asked to run, no pod yet: Stop is the way out if it never comes up. { phase: "Stopped", desiredState: "Running", shows: "servers:stop", disabled: false }, { phase: "Unknown", desiredState: "Running", shows: "servers:stop", disabled: false }, { phase: "Starting", desiredState: "Running", shows: "servers:stop", disabled: false }, // Without desiredState the phase alone decides. { phase: "Starting", shows: "servers:stop", disabled: false }, { phase: "Unknown", shows: "servers:wake", disabled: false }, ]; for (const c of cases) { it(`${c.phase} asked ${c.desiredState ?? "(unknown)"} offers ${c.shows}`, () => { render(<PowerButton name="lobby" phase={c.phase} desiredState={c.desiredState} onChanged={vi.fn()} />); const buttons = screen.getAllByRole("button"); expect(buttons).toHaveLength(1); expect(buttons[0].textContent).toBe(t(c.shows)); expect(buttons[0]).toHaveProperty("disabled", c.disabled); }); } }); panel/src/components/PowerButton.tsx +70 −6 Changes for panel/src/components/PowerButton.tsx: 70 added lines, 6 removed lines. Original line number Diff line number Diff line import { useState } from "react"; import { useEffect, useRef, useState } from "react"; import { Loader2, Play, RotateCcw, Square } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { pendingPower } from "@/components/PhaseBadge"; import { api, humanizeError } from "@/lib/api"; import type { Phase } from "@/lib/types"; import { cn } from "@/lib/utils"; /** How long a sent wake or stop shows as in progress when the view never * reflects it (the parent stopped polling, or someone reversed it at once). */ export const SUBMITTED_HOLD_MS = 20_000; /** How often a sent call asks the parent to refetch until its view shows it. */ export const SUBMITTED_RECHECK_MS = 2_000; interface Props { name: string; /** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */ live: boolean; phase: Phase; /** What the server was last asked to be. Absent in a view without it; the * phase alone then decides. */ desiredState?: "Running" | "Stopped"; /** Its start Failed while meant to run: offer a retry and a stop. */ failed?: boolean; playersOnline?: number; Loading @@ -20,6 +31,10 @@ interface Props { className?: string; } function isUp(phase: Phase): boolean { return phase === "Running" || phase === "Starting"; } // PowerButton starts or stops one server. It is busy while the call runs (no // double send), shows why a call was refused (quota, cooldown, a phase that // moved on), and asks before a stop that would disconnect players: the count Loading @@ -27,9 +42,17 @@ interface Props { // failed gets both ways out: retry (the wake, which felis-api turns into a fresh // start) and stop. Nobody is on a server that never came up, so that stop does // not ask. // // An accepted call keeps its spinner, and keeps asking the parent to reread, // until the parent's view shows the server asked to move (the list and the // console read a cache that lags the write by a moment), so nobody presses Wake // again into a 429. A server on its way down offers nothing until it is down; // one asked to run with no pod yet offers Stop, which is the way out when it // never comes up. export function PowerButton({ name, live, phase, desiredState, failed = false, playersOnline, playerCountUnknown, Loading @@ -39,8 +62,34 @@ export function PowerButton({ }: Props) { const { t } = useTranslation("servers"); const [busy, setBusy] = useState<"wake" | "stop" | null>(null); const [submitted, setSubmitted] = useState<"wake" | "stop" | null>(null); const [confirming, setConfirming] = useState(false); const [error, setError] = useState<string | null>(null); const changed = useRef(onChanged); useEffect(() => { changed.current = onChanged; }, [onChanged]); const pending = pendingPower({ phase, desiredState }); const on = desiredState ? desiredState === "Running" : isUp(phase); // A sent call is done once the view shows the lever moved: a wake when the // server is meant to run (a retry once it is no longer Failed), a stop when it // is meant to stop. The hold ends it anyway if the view never gets there. const caughtUp = submitted === "wake" ? on && !failed : submitted === "stop" ? !on : true; useEffect(() => { if (submitted === null) return; if (caughtUp) { setSubmitted(null); return; } const recheck = window.setInterval(() => changed.current(), SUBMITTED_RECHECK_MS); const hold = window.setTimeout(() => setSubmitted(null), SUBMITTED_HOLD_MS); return () => { window.clearInterval(recheck); window.clearTimeout(hold); }; }, [submitted, caughtUp]); async function run(kind: "wake" | "stop") { if (busy) return; Loading @@ -49,6 +98,7 @@ export function PowerButton({ try { await (kind === "wake" ? api.wake(name) : api.stop(name)); setConfirming(false); setSubmitted(kind); onChanged(); } catch (e) { setError(humanizeError(e)); Loading @@ -66,9 +116,18 @@ export function PowerButton({ {busy === "stop" ? t("stopping") : t("stop")} </Button> ); // inProgress is a server on its way somewhere with nothing to press meanwhile. const inProgress = (kind: "wake" | "stop") => ( <Button size={size} variant={kind === "stop" ? "outline" : "default"} disabled> {spinner} {kind === "wake" ? t("waking") : t("stopping")} </Button> ); let control; if (failed) { if (submitted !== null) { control = inProgress(submitted); } else if (failed) { control = ( <div className="flex flex-wrap items-center justify-end gap-2"> <Button size={size} onClick={() => void run("wake")} disabled={busy !== null}> Loading @@ -78,7 +137,12 @@ export function PowerButton({ {stopButton("outline", () => void run("stop"))} </div> ); } else if (!live) { } else if (pending === "stop" || (phase === "Stopping" && pending === null)) { control = inProgress("stop"); } else if (pending === "start" && phase === "Stopping") { // Woken while stopping: the operator brings it back up once it is down. control = inProgress("wake"); } else if (!on) { control = ( <Button size={size} onClick={() => void run("wake")} disabled={busy !== null}> {busy === "wake" ? spinner : <Play />} Loading panel/src/components/States.test.tsx 0 → 100644 +112 −0 Changes for panel/src/components/States.test.tsx: 112 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { NotRunning, RefreshError } from "./States"; import { humanizeError } from "@/lib/api"; import type { Phase } from "@/lib/types"; import type { StartFailure } from "./PhaseBadge"; const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() })); vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal<typeof import("@/lib/api")>(); return { ...actual, api: { ...actual.api, wake, stop } }; }); beforeEach(() => { wake.mockReset(); stop.mockReset(); }); function notRunning( phase: Phase, desiredState?: "Running" | "Stopped", failure: StartFailure | null = null, onWoken = vi.fn(), ) { return ( <NotRunning title="Server is asleep" body="Wake it to manage players." serverName="survival" phase={phase} desiredState={desiredState} failure={failure} autoRestarts={1} onWoken={onWoken} /> ); } describe("NotRunning", () => { it("offers the wake for a server that is down and meant to stay down", () => { render(notRunning("Stopped", "Stopped")); expect(screen.getByText("Server is asleep")).toBeTruthy(); expect(screen.getByText("Wake it to manage players.")).toBeTruthy(); expect(screen.getByRole("button", { name: "Wake" })).toHaveProperty("disabled", false); }); it("says a woken server is starting and offers nothing to press", () => { for (const [phase, desired] of [ ["Stopped", "Running"], ["Starting", "Running"], ["Starting", undefined], ] as const) { const { unmount } = render(notRunning(phase, desired)); expect(screen.getByText("Server is starting"), `${phase}/${desired}`).toBeTruthy(); expect(screen.getByText(/switches over by itself/), `${phase}/${desired}`).toBeTruthy(); expect(screen.queryByRole("button"), `${phase}/${desired}`).toBeNull(); unmount(); } }); it("says a server on its way down is shutting down", () => { for (const [phase, desired] of [ ["Stopping", undefined], ["Stopping", "Stopped"], ["Running", "Stopped"], ] as const) { const { unmount } = render(notRunning(phase, desired)); expect(screen.getByText("Server is shutting down"), `${phase}/${desired}`).toBeTruthy(); expect(screen.queryByRole("button"), `${phase}/${desired}`).toBeNull(); unmount(); } }); it("offers a failed start its retry and stop", () => { render(notRunning("Failed", "Running", "gaveUp")); expect(screen.getByText("Server failed to start")).toBeTruthy(); expect(screen.getByRole("button", { name: "Retry start" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); }); it("counts the retries of a start the operator is still retrying", () => { render(notRunning("Failed", "Running", "retrying")); expect(screen.getByText("Start timed out — retrying")).toBeTruthy(); expect(screen.getByText(/1 of 3 retries used/)).toBeTruthy(); }); it("turns into the starting notice once the page rereads a wake sent from it", async () => { wake.mockResolvedValue(undefined); const onWoken = vi.fn(); const { rerender } = render(notRunning("Stopped", "Stopped", null, onWoken)); await userEvent.click(screen.getByRole("button", { name: "Wake" })); expect(wake).toHaveBeenCalledWith("survival"); expect(onWoken).toHaveBeenCalled(); expect(screen.getByRole("button", { name: "Waking…" })).toHaveProperty("disabled", true); rerender(notRunning("Stopped", "Running", null, onWoken)); expect(screen.getByRole("status").textContent).toContain("Server is starting"); }); }); describe("RefreshError", () => { it("says the shown status is the last one read, and why the reread failed", () => { const err = { status: 429, code: "quota_exceeded", message: "raw" }; render(<RefreshError error={err} />); const alert = screen.getByRole("alert"); expect(alert.textContent).toContain("Couldn't refresh; what you see is the last status read."); expect(alert.textContent).toContain(humanizeError(err)); }); }); Loading
panel/src/components/PhaseBadge.test.ts +32 −1 Changes for panel/src/components/PhaseBadge.test.ts: 32 added lines, 1 removed line. Original line number Diff line number Diff line import { describe, it, expect } from "vitest"; import { PHASE_COLOR, phaseColor, phaseVariant, startFailure } from "@/components/PhaseBadge"; import { PHASE_COLOR, pendingPower, phaseColor, phaseVariant, shownPhase, startFailure } from "@/components/PhaseBadge"; import type { Phase } from "@/lib/types"; // The closed lifecycle set felis-api can emit — the Go source of truth is Loading Loading @@ -59,3 +59,34 @@ describe("start failure", () => { expect(startFailure({ phase: "Failed", desiredState: "Running", startGaveUp: true })).toBe("gaveUp"); }); }); describe("a server asked to move", () => { // desiredState flips the moment a wake or stop is accepted; the phase follows // once the operator acts. Every pair the two can be in: const cases: [Phase, "Running" | "Stopped" | undefined, "start" | "stop" | null, Phase][] = [ ["Stopped", "Running", "start", "Starting"], ["Unknown", "Running", "start", "Starting"], // Woken while going down: it comes back up once it is down. ["Stopping", "Running", "start", "Starting"], ["Starting", "Running", null, "Starting"], ["Running", "Running", null, "Running"], // A failed start keeps its own badge and retry. ["Failed", "Running", null, "Failed"], ["Running", "Stopped", "stop", "Stopping"], ["Starting", "Stopped", "stop", "Stopping"], ["Stopping", "Stopped", null, "Stopping"], ["Stopped", "Stopped", null, "Stopped"], ["Failed", "Stopped", null, "Failed"], ["Unknown", "Stopped", null, "Unknown"], // A view without desiredState shows the phase as it is. ["Stopped", undefined, null, "Stopped"], ["Running", undefined, null, "Running"], ]; for (const [phase, desiredState, pending, shown] of cases) { it(`${phase} asked ${desiredState ?? "(unknown)"} is pending ${pending} and shows ${shown}`, () => { expect(pendingPower({ phase, desiredState })).toBe(pending); expect(shownPhase({ phase, desiredState })).toBe(shown); }); } });
panel/src/components/PhaseBadge.tsx +23 −0 Changes for panel/src/components/PhaseBadge.tsx: 23 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -72,6 +72,29 @@ export function startFailure(s: { return s.startGaveUp ? "gaveUp" : "retrying"; } /** PendingPower is a server asked to move that has not got there yet: asked to * run with no pod up ("start"), or asked to stop while still up ("stop"). */ export type PendingPower = "start" | "stop"; /** pendingPower compares the phase with desiredState, the lever a wake or stop * sets at once; the phase follows once the operator acts. Without desiredState * (a public view) nothing reads as pending. */ export function pendingPower(s: { phase: Phase; desiredState?: string }): PendingPower | null { if (s.desiredState === "Running" && (s.phase === "Stopped" || s.phase === "Stopping" || s.phase === "Unknown")) { return "start"; } if (s.desiredState === "Stopped" && (s.phase === "Running" || s.phase === "Starting")) return "stop"; return null; } /** shownPhase is the phase a person should see: one already asked to start reads * Starting, one asked to stop reads Stopping, so a wake or stop shows at once * instead of the old phase lingering until the operator gets to it. */ export function shownPhase(s: { phase: Phase; desiredState?: string }): Phase { const pending = pendingPower(s); return pending === "start" ? "Starting" : pending === "stop" ? "Stopping" : s.phase; } export function PhaseBadge({ phase, failure = null, Loading
panel/src/components/PowerButton.test.tsx +134 −12 Changes for panel/src/components/PowerButton.test.tsx: 134 added lines, 12 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import i18next from "i18next"; import { PowerButton } from "./PowerButton"; import type { Phase } from "@/lib/types"; import { PowerButton, SUBMITTED_HOLD_MS, SUBMITTED_RECHECK_MS } from "./PowerButton"; const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() })); vi.mock("@/lib/api", async (importOriginal) => { Loading @@ -18,11 +19,16 @@ beforeEach(() => { stop.mockReset(); }); // A fake-timer test that fails must not leave the clock frozen for the rest. afterEach(() => { vi.useRealTimers(); }); describe("PowerButton", () => { it("wakes a stopped server and tells the parent", async () => { wake.mockResolvedValue(undefined); const onChanged = vi.fn(); render(<PowerButton name="lobby" live={false} onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:wake") })); Loading @@ -33,7 +39,7 @@ describe("PowerButton", () => { it("stays on the page with the reason when the wake is refused", async () => { wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" }); const onChanged = vi.fn(); render(<PowerButton name="lobby" live={false} onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:wake") })); Loading @@ -45,7 +51,7 @@ describe("PowerButton", () => { it("stops an empty server without asking", async () => { stop.mockResolvedValue(undefined); const onChanged = vi.fn(); render(<PowerButton name="lobby" live playersOnline={0} onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); Loading @@ -54,7 +60,7 @@ describe("PowerButton", () => { }); it("asks before disconnecting players, and cancel sends nothing", async () => { render(<PowerButton name="lobby" live playersOnline={3} onChanged={vi.fn()} />); render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={3} onChanged={vi.fn()} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy(); Loading @@ -67,7 +73,7 @@ describe("PowerButton", () => { it("asks when the player count cannot be read", async () => { stop.mockResolvedValue(undefined); const onChanged = vi.fn(); render(<PowerButton name="lobby" live playersOnline={0} playerCountUnknown onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} playerCountUnknown onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(stop).not.toHaveBeenCalled(); Loading @@ -81,7 +87,7 @@ describe("PowerButton", () => { it("keeps the confirmation open with the reason when the stop fails", async () => { stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" }); const onChanged = vi.fn(); render(<PowerButton name="lobby" live playersOnline={2} onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={2} onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); Loading @@ -94,7 +100,7 @@ describe("PowerButton", () => { it("sends one call however fast it is clicked", async () => { let resolve!: () => void; wake.mockReturnValue(new Promise<void>((r) => (resolve = r))); render(<PowerButton name="lobby" live={false} onChanged={vi.fn()} />); render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); const button = screen.getByRole("button", { name: t("servers:wake") }); await userEvent.click(button); Loading @@ -107,7 +113,7 @@ describe("PowerButton", () => { it("offers a failed server a retry, which is the wake", async () => { wake.mockResolvedValue(undefined); const onChanged = vi.fn(); render(<PowerButton name="lobby" live={false} failed onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={onChanged} />); expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull(); await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") })); Loading @@ -121,7 +127,7 @@ describe("PowerButton", () => { let resolve!: () => void; stop.mockReturnValue(new Promise<void>((r) => (resolve = r))); const onChanged = vi.fn(); render(<PowerButton name="lobby" live={false} failed playerCountUnknown onChanged={onChanged} />); render(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed playerCountUnknown onChanged={onChanged} />); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); Loading @@ -133,3 +139,119 @@ describe("PowerButton", () => { await vi.waitFor(() => expect(onChanged).toHaveBeenCalledOnce()); }); }); describe("PowerButton after an accepted call", () => { const ok = () => wake.mockResolvedValue(undefined); it("keeps the wake in progress until the view shows it, then offers the stop", async () => { ok(); const { rerender } = render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); await userEvent.click(screen.getByRole("button", { name: t("servers:wake") })); // The parent's first reread can still carry the old status. rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true); expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull(); rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Running" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:stop") })).toHaveProperty("disabled", false); }); it("asks the parent to reread until the view catches up, and no longer after", async () => { vi.useFakeTimers(); ok(); const onChanged = vi.fn(); const { rerender } = render( <PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />, ); await act(async () => { fireEvent.click(screen.getByRole("button", { name: t("servers:wake") })); }); const afterClick = onChanged.mock.calls.length; await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_RECHECK_MS * 2)); expect(onChanged.mock.calls.length).toBe(afterClick + 2); rerender(<PowerButton name="lobby" phase="Starting" desiredState="Running" onChanged={onChanged} />); const caughtUp = onChanged.mock.calls.length; await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_RECHECK_MS * 3)); expect(onChanged.mock.calls.length).toBe(caughtUp); }); it("gives the button back when the view never catches up", async () => { vi.useFakeTimers(); ok(); render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); await act(async () => { fireEvent.click(screen.getByRole("button", { name: t("servers:wake") })); }); expect(screen.getByRole("button", { name: t("servers:waking") })).toBeTruthy(); await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_HOLD_MS - 1)); expect(screen.getByRole("button", { name: t("servers:waking") })).toBeTruthy(); await act(() => vi.advanceTimersByTimeAsync(1)); expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false); }); it("keeps a stop in progress until the server is down", async () => { stop.mockResolvedValue(undefined); const { rerender } = render( <PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} onChanged={vi.fn()} />, ); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true); rerender(<PowerButton name="lobby" phase="Running" desiredState="Stopped" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true); rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false); }); it("keeps a retry in progress while the view still shows the failure", async () => { ok(); const { rerender } = render( <PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={vi.fn()} />, ); await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") })); rerender(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true); expect(screen.queryByRole("button", { name: t("servers:retry_start") })).toBeNull(); rerender(<PowerButton name="lobby" phase="Starting" desiredState="Running" onChanged={vi.fn()} />); expect(screen.getByRole("button", { name: t("servers:stop") })).toBeTruthy(); }); }); describe("PowerButton on a server already moving", () => { const cases: { phase: Phase; desiredState?: "Running" | "Stopped"; shows: string; disabled: boolean; }[] = [ { phase: "Stopping", shows: "servers:stopping", disabled: true }, { phase: "Stopping", desiredState: "Stopped", shows: "servers:stopping", disabled: true }, { phase: "Running", desiredState: "Stopped", shows: "servers:stopping", disabled: true }, { phase: "Starting", desiredState: "Stopped", shows: "servers:stopping", disabled: true }, // Woken while going down: it comes back up once it is down. { phase: "Stopping", desiredState: "Running", shows: "servers:waking", disabled: true }, // Asked to run, no pod yet: Stop is the way out if it never comes up. { phase: "Stopped", desiredState: "Running", shows: "servers:stop", disabled: false }, { phase: "Unknown", desiredState: "Running", shows: "servers:stop", disabled: false }, { phase: "Starting", desiredState: "Running", shows: "servers:stop", disabled: false }, // Without desiredState the phase alone decides. { phase: "Starting", shows: "servers:stop", disabled: false }, { phase: "Unknown", shows: "servers:wake", disabled: false }, ]; for (const c of cases) { it(`${c.phase} asked ${c.desiredState ?? "(unknown)"} offers ${c.shows}`, () => { render(<PowerButton name="lobby" phase={c.phase} desiredState={c.desiredState} onChanged={vi.fn()} />); const buttons = screen.getAllByRole("button"); expect(buttons).toHaveLength(1); expect(buttons[0].textContent).toBe(t(c.shows)); expect(buttons[0]).toHaveProperty("disabled", c.disabled); }); } });
panel/src/components/PowerButton.tsx +70 −6 Changes for panel/src/components/PowerButton.tsx: 70 added lines, 6 removed lines. Original line number Diff line number Diff line import { useState } from "react"; import { useEffect, useRef, useState } from "react"; import { Loader2, Play, RotateCcw, Square } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { pendingPower } from "@/components/PhaseBadge"; import { api, humanizeError } from "@/lib/api"; import type { Phase } from "@/lib/types"; import { cn } from "@/lib/utils"; /** How long a sent wake or stop shows as in progress when the view never * reflects it (the parent stopped polling, or someone reversed it at once). */ export const SUBMITTED_HOLD_MS = 20_000; /** How often a sent call asks the parent to refetch until its view shows it. */ export const SUBMITTED_RECHECK_MS = 2_000; interface Props { name: string; /** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */ live: boolean; phase: Phase; /** What the server was last asked to be. Absent in a view without it; the * phase alone then decides. */ desiredState?: "Running" | "Stopped"; /** Its start Failed while meant to run: offer a retry and a stop. */ failed?: boolean; playersOnline?: number; Loading @@ -20,6 +31,10 @@ interface Props { className?: string; } function isUp(phase: Phase): boolean { return phase === "Running" || phase === "Starting"; } // PowerButton starts or stops one server. It is busy while the call runs (no // double send), shows why a call was refused (quota, cooldown, a phase that // moved on), and asks before a stop that would disconnect players: the count Loading @@ -27,9 +42,17 @@ interface Props { // failed gets both ways out: retry (the wake, which felis-api turns into a fresh // start) and stop. Nobody is on a server that never came up, so that stop does // not ask. // // An accepted call keeps its spinner, and keeps asking the parent to reread, // until the parent's view shows the server asked to move (the list and the // console read a cache that lags the write by a moment), so nobody presses Wake // again into a 429. A server on its way down offers nothing until it is down; // one asked to run with no pod yet offers Stop, which is the way out when it // never comes up. export function PowerButton({ name, live, phase, desiredState, failed = false, playersOnline, playerCountUnknown, Loading @@ -39,8 +62,34 @@ export function PowerButton({ }: Props) { const { t } = useTranslation("servers"); const [busy, setBusy] = useState<"wake" | "stop" | null>(null); const [submitted, setSubmitted] = useState<"wake" | "stop" | null>(null); const [confirming, setConfirming] = useState(false); const [error, setError] = useState<string | null>(null); const changed = useRef(onChanged); useEffect(() => { changed.current = onChanged; }, [onChanged]); const pending = pendingPower({ phase, desiredState }); const on = desiredState ? desiredState === "Running" : isUp(phase); // A sent call is done once the view shows the lever moved: a wake when the // server is meant to run (a retry once it is no longer Failed), a stop when it // is meant to stop. The hold ends it anyway if the view never gets there. const caughtUp = submitted === "wake" ? on && !failed : submitted === "stop" ? !on : true; useEffect(() => { if (submitted === null) return; if (caughtUp) { setSubmitted(null); return; } const recheck = window.setInterval(() => changed.current(), SUBMITTED_RECHECK_MS); const hold = window.setTimeout(() => setSubmitted(null), SUBMITTED_HOLD_MS); return () => { window.clearInterval(recheck); window.clearTimeout(hold); }; }, [submitted, caughtUp]); async function run(kind: "wake" | "stop") { if (busy) return; Loading @@ -49,6 +98,7 @@ export function PowerButton({ try { await (kind === "wake" ? api.wake(name) : api.stop(name)); setConfirming(false); setSubmitted(kind); onChanged(); } catch (e) { setError(humanizeError(e)); Loading @@ -66,9 +116,18 @@ export function PowerButton({ {busy === "stop" ? t("stopping") : t("stop")} </Button> ); // inProgress is a server on its way somewhere with nothing to press meanwhile. const inProgress = (kind: "wake" | "stop") => ( <Button size={size} variant={kind === "stop" ? "outline" : "default"} disabled> {spinner} {kind === "wake" ? t("waking") : t("stopping")} </Button> ); let control; if (failed) { if (submitted !== null) { control = inProgress(submitted); } else if (failed) { control = ( <div className="flex flex-wrap items-center justify-end gap-2"> <Button size={size} onClick={() => void run("wake")} disabled={busy !== null}> Loading @@ -78,7 +137,12 @@ export function PowerButton({ {stopButton("outline", () => void run("stop"))} </div> ); } else if (!live) { } else if (pending === "stop" || (phase === "Stopping" && pending === null)) { control = inProgress("stop"); } else if (pending === "start" && phase === "Stopping") { // Woken while stopping: the operator brings it back up once it is down. control = inProgress("wake"); } else if (!on) { control = ( <Button size={size} onClick={() => void run("wake")} disabled={busy !== null}> {busy === "wake" ? spinner : <Play />} Loading
panel/src/components/States.test.tsx 0 → 100644 +112 −0 Changes for panel/src/components/States.test.tsx: 112 added lines, 0 removed lines. Original line number Diff line number Diff line // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { NotRunning, RefreshError } from "./States"; import { humanizeError } from "@/lib/api"; import type { Phase } from "@/lib/types"; import type { StartFailure } from "./PhaseBadge"; const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() })); vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal<typeof import("@/lib/api")>(); return { ...actual, api: { ...actual.api, wake, stop } }; }); beforeEach(() => { wake.mockReset(); stop.mockReset(); }); function notRunning( phase: Phase, desiredState?: "Running" | "Stopped", failure: StartFailure | null = null, onWoken = vi.fn(), ) { return ( <NotRunning title="Server is asleep" body="Wake it to manage players." serverName="survival" phase={phase} desiredState={desiredState} failure={failure} autoRestarts={1} onWoken={onWoken} /> ); } describe("NotRunning", () => { it("offers the wake for a server that is down and meant to stay down", () => { render(notRunning("Stopped", "Stopped")); expect(screen.getByText("Server is asleep")).toBeTruthy(); expect(screen.getByText("Wake it to manage players.")).toBeTruthy(); expect(screen.getByRole("button", { name: "Wake" })).toHaveProperty("disabled", false); }); it("says a woken server is starting and offers nothing to press", () => { for (const [phase, desired] of [ ["Stopped", "Running"], ["Starting", "Running"], ["Starting", undefined], ] as const) { const { unmount } = render(notRunning(phase, desired)); expect(screen.getByText("Server is starting"), `${phase}/${desired}`).toBeTruthy(); expect(screen.getByText(/switches over by itself/), `${phase}/${desired}`).toBeTruthy(); expect(screen.queryByRole("button"), `${phase}/${desired}`).toBeNull(); unmount(); } }); it("says a server on its way down is shutting down", () => { for (const [phase, desired] of [ ["Stopping", undefined], ["Stopping", "Stopped"], ["Running", "Stopped"], ] as const) { const { unmount } = render(notRunning(phase, desired)); expect(screen.getByText("Server is shutting down"), `${phase}/${desired}`).toBeTruthy(); expect(screen.queryByRole("button"), `${phase}/${desired}`).toBeNull(); unmount(); } }); it("offers a failed start its retry and stop", () => { render(notRunning("Failed", "Running", "gaveUp")); expect(screen.getByText("Server failed to start")).toBeTruthy(); expect(screen.getByRole("button", { name: "Retry start" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); }); it("counts the retries of a start the operator is still retrying", () => { render(notRunning("Failed", "Running", "retrying")); expect(screen.getByText("Start timed out — retrying")).toBeTruthy(); expect(screen.getByText(/1 of 3 retries used/)).toBeTruthy(); }); it("turns into the starting notice once the page rereads a wake sent from it", async () => { wake.mockResolvedValue(undefined); const onWoken = vi.fn(); const { rerender } = render(notRunning("Stopped", "Stopped", null, onWoken)); await userEvent.click(screen.getByRole("button", { name: "Wake" })); expect(wake).toHaveBeenCalledWith("survival"); expect(onWoken).toHaveBeenCalled(); expect(screen.getByRole("button", { name: "Waking…" })).toHaveProperty("disabled", true); rerender(notRunning("Stopped", "Running", null, onWoken)); expect(screen.getByRole("status").textContent).toContain("Server is starting"); }); }); describe("RefreshError", () => { it("says the shown status is the last one read, and why the reread failed", () => { const err = { status: 429, code: "quota_exceeded", message: "raw" }; render(<RefreshError error={err} />); const alert = screen.getByRole("alert"); expect(alert.textContent).toContain("Couldn't refresh; what you see is the last status read."); expect(alert.textContent).toContain(humanizeError(err)); }); });