diff --git a/panel/src/components/PhaseBadge.test.ts b/panel/src/components/PhaseBadge.test.ts index 6868243..58f7472 100644 --- a/panel/src/components/PhaseBadge.test.ts +++ b/panel/src/components/PhaseBadge.test.ts @@ -1,5 +1,5 @@ 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 @@ -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); + }); + } +}); diff --git a/panel/src/components/PhaseBadge.tsx b/panel/src/components/PhaseBadge.tsx index 5424049..11ac3e3 100644 --- a/panel/src/components/PhaseBadge.tsx +++ b/panel/src/components/PhaseBadge.tsx @@ -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, diff --git a/panel/src/components/PowerButton.test.tsx b/panel/src/components/PowerButton.test.tsx index f6a7411..8216826 100644 --- a/panel/src/components/PowerButton.test.tsx +++ b/panel/src/components/PowerButton.test.tsx @@ -1,9 +1,10 @@ // @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) => { @@ -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(); + render(); await userEvent.click(screen.getByRole("button", { name: t("servers:wake") })); @@ -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(); + render(); await userEvent.click(screen.getByRole("button", { name: t("servers:wake") })); @@ -45,7 +51,7 @@ describe("PowerButton", () => { it("stops an empty server without asking", async () => { stop.mockResolvedValue(undefined); const onChanged = vi.fn(); - render(); + render(); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); @@ -54,7 +60,7 @@ describe("PowerButton", () => { }); it("asks before disconnecting players, and cancel sends nothing", async () => { - render(); + render(); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy(); @@ -67,7 +73,7 @@ describe("PowerButton", () => { it("asks when the player count cannot be read", async () => { stop.mockResolvedValue(undefined); const onChanged = vi.fn(); - render(); + render(); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); expect(stop).not.toHaveBeenCalled(); @@ -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(); + render(); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); @@ -94,7 +100,7 @@ describe("PowerButton", () => { it("sends one call however fast it is clicked", async () => { let resolve!: () => void; wake.mockReturnValue(new Promise((r) => (resolve = r))); - render(); + render(); const button = screen.getByRole("button", { name: t("servers:wake") }); await userEvent.click(button); @@ -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(); + render(); expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull(); await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") })); @@ -121,7 +127,7 @@ describe("PowerButton", () => { let resolve!: () => void; stop.mockReturnValue(new Promise((r) => (resolve = r))); const onChanged = vi.fn(); - render(); + render(); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); @@ -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(); + await userEvent.click(screen.getByRole("button", { name: t("servers:wake") })); + + // The parent's first reread can still carry the old status. + rerender(); + expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true); + expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull(); + + rerender(); + 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( + , + ); + 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(); + 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(); + 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( + , + ); + await userEvent.click(screen.getByRole("button", { name: t("servers:stop") })); + expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true); + + rerender(); + expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true); + rerender(); + 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( + , + ); + await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") })); + + rerender(); + expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true); + expect(screen.queryByRole("button", { name: t("servers:retry_start") })).toBeNull(); + + rerender(); + 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(); + const buttons = screen.getAllByRole("button"); + expect(buttons).toHaveLength(1); + expect(buttons[0].textContent).toBe(t(c.shows)); + expect(buttons[0]).toHaveProperty("disabled", c.disabled); + }); + } +}); diff --git a/panel/src/components/PowerButton.tsx b/panel/src/components/PowerButton.tsx index 56734b9..b80ffb7 100644 --- a/panel/src/components/PowerButton.tsx +++ b/panel/src/components/PowerButton.tsx @@ -1,14 +1,25 @@ -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; @@ -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 @@ -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, @@ -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(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; @@ -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)); @@ -66,9 +116,18 @@ export function PowerButton({ {busy === "stop" ? t("stopping") : t("stop")} ); + // inProgress is a server on its way somewhere with nothing to press meanwhile. + const inProgress = (kind: "wake" | "stop") => ( + + ); let control; - if (failed) { + if (submitted !== null) { + control = inProgress(submitted); + } else if (failed) { control = (
); - } 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 = (