fix(panel): 控制台、玩家页、权限页、文件页和仪表盘自动刷新状态,唤醒或停止后立即显示进行中

This commit is contained in:
Lemon-miaow committed 2026-09-26 22:42:33 +08:00
1 parent d65cf3af30
commit fda14e9e45
24 files changed
+971 -116

No files matched your search

+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; 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"; import type { Phase } from "@/lib/types";
// The closed lifecycle set felis-api can emit — the Go source of truth is // 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"); 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);
});
}
});
+23
View File
@@ -72,6 +72,29 @@ export function startFailure(s: {
return s.startGaveUp ? "gaveUp" : "retrying"; 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({ export function PhaseBadge({
phase, phase,
failure = null, failure = null,
+134 -12
View File
@@ -1,9 +1,10 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import i18next from "i18next"; 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() })); const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => { vi.mock("@/lib/api", async (importOriginal) => {
@@ -18,11 +19,16 @@ beforeEach(() => {
stop.mockReset(); stop.mockReset();
}); });
// A fake-timer test that fails must not leave the clock frozen for the rest.
afterEach(() => {
vi.useRealTimers();
});
describe("PowerButton", () => { describe("PowerButton", () => {
it("wakes a stopped server and tells the parent", async () => { it("wakes a stopped server and tells the parent", async () => {
wake.mockResolvedValue(undefined); wake.mockResolvedValue(undefined);
const onChanged = vi.fn(); 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") })); 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 () => { it("stays on the page with the reason when the wake is refused", async () => {
wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" }); wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" });
const onChanged = vi.fn(); 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") })); await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
@@ -45,7 +51,7 @@ describe("PowerButton", () => {
it("stops an empty server without asking", async () => { it("stops an empty server without asking", async () => {
stop.mockResolvedValue(undefined); stop.mockResolvedValue(undefined);
const onChanged = vi.fn(); 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") })); 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 () => { 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") })); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy(); 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 () => { it("asks when the player count cannot be read", async () => {
stop.mockResolvedValue(undefined); stop.mockResolvedValue(undefined);
const onChanged = vi.fn(); 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") })); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(stop).not.toHaveBeenCalled(); expect(stop).not.toHaveBeenCalled();
@@ -81,7 +87,7 @@ describe("PowerButton", () => {
it("keeps the confirmation open with the reason when the stop fails", async () => { it("keeps the confirmation open with the reason when the stop fails", async () => {
stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" }); stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" });
const onChanged = vi.fn(); 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") }));
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 () => { it("sends one call however fast it is clicked", async () => {
let resolve!: () => void; let resolve!: () => void;
wake.mockReturnValue(new Promise<void>((r) => (resolve = r))); 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") }); const button = screen.getByRole("button", { name: t("servers:wake") });
await userEvent.click(button); await userEvent.click(button);
@@ -107,7 +113,7 @@ describe("PowerButton", () => {
it("offers a failed server a retry, which is the wake", async () => { it("offers a failed server a retry, which is the wake", async () => {
wake.mockResolvedValue(undefined); wake.mockResolvedValue(undefined);
const onChanged = vi.fn(); 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(); expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") })); await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") }));
@@ -121,7 +127,7 @@ describe("PowerButton", () => {
let resolve!: () => void; let resolve!: () => void;
stop.mockReturnValue(new Promise<void>((r) => (resolve = r))); stop.mockReturnValue(new Promise<void>((r) => (resolve = r)));
const onChanged = vi.fn(); 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") })); await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
@@ -133,3 +139,119 @@ describe("PowerButton", () => {
await vi.waitFor(() => expect(onChanged).toHaveBeenCalledOnce()); 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);
});
}
});
+70 -6
View File
@@ -1,14 +1,25 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Loader2, Play, RotateCcw, Square } from "lucide-react"; import { Loader2, Play, RotateCcw, Square } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { pendingPower } from "@/components/PhaseBadge";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils"; 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 { interface Props {
name: string; name: string;
/** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */ phase: Phase;
live: boolean; /** 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. */ /** Its start Failed while meant to run: offer a retry and a stop. */
failed?: boolean; failed?: boolean;
playersOnline?: number; playersOnline?: number;
@@ -20,6 +31,10 @@ interface Props {
className?: string; 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 // 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 // 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 // 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 // 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 // start) and stop. Nobody is on a server that never came up, so that stop does
// not ask. // 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({ export function PowerButton({
name, name,
live, phase,
desiredState,
failed = false, failed = false,
playersOnline, playersOnline,
playerCountUnknown, playerCountUnknown,
@@ -39,8 +62,34 @@ export function PowerButton({
}: Props) { }: Props) {
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
const [busy, setBusy] = useState<"wake" | "stop" | null>(null); const [busy, setBusy] = useState<"wake" | "stop" | null>(null);
const [submitted, setSubmitted] = useState<"wake" | "stop" | null>(null);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null); 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") { async function run(kind: "wake" | "stop") {
if (busy) return; if (busy) return;
@@ -49,6 +98,7 @@ export function PowerButton({
try { try {
await (kind === "wake" ? api.wake(name) : api.stop(name)); await (kind === "wake" ? api.wake(name) : api.stop(name));
setConfirming(false); setConfirming(false);
setSubmitted(kind);
onChanged(); onChanged();
} catch (e) { } catch (e) {
setError(humanizeError(e)); setError(humanizeError(e));
@@ -66,9 +116,18 @@ export function PowerButton({
{busy === "stop" ? t("stopping") : t("stop")} {busy === "stop" ? t("stopping") : t("stop")}
</Button> </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; let control;
if (failed) { if (submitted !== null) {
control = inProgress(submitted);
} else if (failed) {
control = ( control = (
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
<Button size={size} onClick={() => void run("wake")} disabled={busy !== null}> <Button size={size} onClick={() => void run("wake")} disabled={busy !== null}>
@@ -78,7 +137,12 @@ export function PowerButton({
{stopButton("outline", () => void run("stop"))} {stopButton("outline", () => void run("stop"))}
</div> </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 = ( control = (
<Button size={size} onClick={() => void run("wake")} disabled={busy !== null}> <Button size={size} onClick={() => void run("wake")} disabled={busy !== null}>
{busy === "wake" ? spinner : <Play />} {busy === "wake" ? spinner : <Play />}
+112
View File
@@ -0,0 +1,112 @@
// @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));
});
});
+84 -6
View File
@@ -1,8 +1,11 @@
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react"; import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MAX_AUTO_RESTARTS, shownPhase, type StartFailure } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton"; import { PowerButton } from "@/components/PowerButton";
import { humanizeError } from "@/lib/api"; import { humanizeError } from "@/lib/api";
import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils";
export function Loading({ label }: { label?: string }) { export function Loading({ label }: { label?: string }) {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
@@ -32,6 +35,26 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () =>
); );
} }
// RefreshError sits above a page whose last read still shows while a later one
// failed: the page stays usable, and says the status may be out of date.
export function RefreshError({ error, className }: { error: unknown; className?: string }) {
const { t } = useTranslation("common");
return (
<div
role="alert"
className={cn(
"flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive",
className,
)}
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span>
{t("refresh_failed")} {humanizeError(error)}
</span>
</div>
);
}
export function EmptyState({ export function EmptyState({
title, title,
hint, hint,
@@ -141,23 +164,78 @@ export function NotYours({ title, body, linkTo = "/servers", linkLabel }: NotYou
} }
export interface NotRunningProps { export interface NotRunningProps {
/** What the page says about a server that is down, and why it needs it up. */
title: string; title: string;
body: string; body: string;
/** The server to wake from here. */ /** The server to wake from here. */
serverName: string; serverName: string;
phase: Phase;
desiredState?: "Running" | "Stopped";
/** From startFailure: a Failed start gets its own copy and a retry. */
failure?: StartFailure | null;
autoRestarts?: number;
/** Called once the wake was accepted, so the page refetches its status. */ /** Called once the wake was accepted, so the page refetches its status. */
onWoken: () => void; onWoken: () => void;
} }
export function NotRunning({ title, body, serverName, onWoken }: NotRunningProps) { // NotRunning stands in for a page that needs the server up. It follows where the
// server is heading, so a wake from here reads as starting until the page's poll
// finds it running and switches over, a server going down says so, and a failed
// start offers the retry.
export function NotRunning({
title,
body,
serverName,
phase,
desiredState,
failure = null,
autoRestarts = 0,
onWoken,
}: NotRunningProps) {
const { t } = useTranslation("servers");
const shown = shownPhase({ phase, desiredState });
const transitional = failure === null && (shown === "Starting" || shown === "Stopping");
let icon = <Moon className="h-8 w-8 text-muted-foreground/70" />;
let copy = { title, body };
if (failure !== null) {
icon = <AlertTriangle className="h-8 w-8 text-destructive" />;
copy =
failure === "retrying"
? {
title: t("server_failed_retrying_title"),
body: t("server_failed_retrying_body", { used: autoRestarts, max: MAX_AUTO_RESTARTS }),
}
: { title: t("server_failed_title"), body: t("server_failed_body") };
} else if (transitional) {
icon = <Loader2 className="h-8 w-8 animate-spin text-muted-foreground/70" />;
copy =
shown === "Starting"
? { title: t("server_waking_title"), body: t("server_waking_body") }
: { title: t("server_stopping_title"), body: t("server_stopping_body") };
}
return ( return (
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center"> <div
<Moon className="h-8 w-8 text-muted-foreground/70" /> role="status"
aria-live="polite"
className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center"
>
{icon}
<div> <div>
<p className="font-medium">{title}</p> <p className="font-medium">{copy.title}</p>
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{body}</p> <p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{copy.body}</p>
</div> </div>
<PowerButton name={serverName} live={false} onChanged={onWoken} className="items-center" /> {!transitional && (
<PowerButton
name={serverName}
phase={phase}
desiredState={desiredState}
failed={failure !== null}
onChanged={onWoken}
className="items-center"
/>
)}
</div> </div>
); );
} }
+2 -1
View File
@@ -37,5 +37,6 @@
"open_menu": "Open menu", "open_menu": "Open menu",
"copy_address": "Copy address {{address}}", "copy_address": "Copy address {{address}}",
"address_copied": "Copied", "address_copied": "Copied",
"address_copy_failed": "Selected — copy it from the menu" "address_copy_failed": "Selected — copy it from the menu",
"refresh_failed": "Couldn't refresh; what you see is the last status read."
} }
@@ -30,6 +30,10 @@
"server_asleep_body": "Wake it to boot a pod — the live console attaches automatically the moment it starts up.", "server_asleep_body": "Wake it to boot a pod — the live console attaches automatically the moment it starts up.",
"server_shutting_down_title": "Server is shutting down", "server_shutting_down_title": "Server is shutting down",
"server_shutting_down_body": "The pod is terminating, so the live console has detached. It will be asleep in a moment.", "server_shutting_down_body": "The pod is terminating, so the live console has detached. It will be asleep in a moment.",
"server_waking_title": "Server is starting",
"server_waking_body": "This usually takes a minute or two. The page switches over by itself once the server is running.",
"server_stopping_title": "Server is shutting down",
"server_stopping_body": "Once it is down you can wake it again from here.",
"server_failed_title": "Server failed to start", "server_failed_title": "Server failed to start",
"server_failed_body": "Its automatic retries are spent. The console holds the log of the last attempt; fix the cause, then press Retry start.", "server_failed_body": "Its automatic retries are spent. The console holds the log of the last attempt; fix the cause, then press Retry start.",
"server_failed_retrying_title": "Start timed out — retrying", "server_failed_retrying_title": "Start timed out — retrying",
+2 -1
View File
@@ -37,5 +37,6 @@
"open_menu": "打开菜单", "open_menu": "打开菜单",
"copy_address": "复制地址 {{address}}", "copy_address": "复制地址 {{address}}",
"address_copied": "已复制", "address_copied": "已复制",
"address_copy_failed": "已选中,请用系统菜单复制" "address_copy_failed": "已选中,请用系统菜单复制",
"refresh_failed": "刷新失败,下面是上次读到的状态。"
} }
@@ -30,6 +30,10 @@
"server_asleep_body": "启动后将拉起 Pod,实时控制台会自动连接。", "server_asleep_body": "启动后将拉起 Pod,实时控制台会自动连接。",
"server_shutting_down_title": "服务器正在关闭", "server_shutting_down_title": "服务器正在关闭",
"server_shutting_down_body": "Pod 正在终止,实时控制台已断开。关闭完成后将进入休眠。", "server_shutting_down_body": "Pod 正在终止,实时控制台已断开。关闭完成后将进入休眠。",
"server_waking_title": "服务器正在启动",
"server_waking_body": "一般需要一两分钟。服务器运行后,这个页面会自动切换过来。",
"server_stopping_title": "服务器正在关闭",
"server_stopping_body": "关闭完成后可以在这里重新启动。",
"server_failed_title": "服务器启动失败", "server_failed_title": "服务器启动失败",
"server_failed_body": "自动重试已用完。控制台里是最后一次启动的日志,找到原因修好后点「重试启动」。", "server_failed_body": "自动重试已用完。控制台里是最后一次启动的日志,找到原因修好后点「重试启动」。",
"server_failed_retrying_title": "启动超时,正在自动重试", "server_failed_retrying_title": "启动超时,正在自动重试",
+56 -2
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import { useAsync, type AsyncOptions } from "./hooks"; import { useAsync, usePolling, type AsyncOptions } from "./hooks";
// A request the test settles by hand, one per call, in call order. // A request the test settles by hand, one per call, in call order.
function requests() { function requests() {
@@ -100,3 +100,57 @@ describe("useAsync", () => {
expect(h.result.current.data).toBe("server b"); expect(h.result.current.data).toBe("server b");
}); });
}); });
describe("usePolling", () => {
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(document, "hidden", { configurable: true, value: false });
});
function setHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", { configurable: true, value: hidden });
document.dispatchEvent(new Event("visibilitychange"));
}
it("reloads on every tick while the tab is in view, and at once when it comes back", () => {
vi.useFakeTimers();
const reload = vi.fn();
renderHook(() => usePolling(reload, 1000));
vi.advanceTimersByTime(2500);
expect(reload).toHaveBeenCalledTimes(2);
setHidden(true);
vi.advanceTimersByTime(5000);
expect(reload).toHaveBeenCalledTimes(2);
setHidden(false);
expect(reload).toHaveBeenCalledTimes(3);
});
it("follows a new interval and stops when paused or unmounted", () => {
vi.useFakeTimers();
const reload = vi.fn();
const hook = renderHook(({ ms }: { ms: number | null }) => usePolling(reload, ms), {
initialProps: { ms: 1000 as number | null },
});
vi.advanceTimersByTime(1000);
expect(reload).toHaveBeenCalledTimes(1);
hook.rerender({ ms: 5000 });
vi.advanceTimersByTime(4000);
expect(reload).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000);
expect(reload).toHaveBeenCalledTimes(2);
hook.rerender({ ms: null });
vi.advanceTimersByTime(20000);
setHidden(false);
expect(reload).toHaveBeenCalledTimes(2);
hook.rerender({ ms: 1000 });
hook.unmount();
vi.advanceTimersByTime(5000);
expect(reload).toHaveBeenCalledTimes(2);
});
});
+23
View File
@@ -112,3 +112,26 @@ export function useUnsavedGuard(dirty: boolean): void {
return () => window.removeEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [dirty]); }, [dirty]);
} }
/** How often a server page rereads its status: fast while it waits on the
* server (coming up, going down, or needed up), slow while nothing is due. */
export const STATUS_POLL_FAST_MS = 4_000;
export const STATUS_POLL_SLOW_MS = 15_000;
/** usePolling re-runs `reload` every `ms` while the tab is visible, and at once
* when the tab comes back into view, so a page watching a server follows it
* without a manual refresh. `ms` null pauses it. */
export function usePolling(reload: () => void, ms: number | null): void {
useEffect(() => {
if (ms === null) return;
const tick = () => {
if (!document.hidden) reload();
};
const id = window.setInterval(tick, ms);
document.addEventListener("visibilitychange", tick);
return () => {
window.clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
}, [reload, ms]);
}
+39 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
@@ -158,3 +158,41 @@ describe("Dashboard", () => {
expect(calls.listImages).not.toHaveBeenCalled(); expect(calls.listImages).not.toHaveBeenCalled();
}); });
}); });
describe("Dashboard following the fleet", () => {
afterEach(() => {
vi.useRealTimers();
});
// The fleet grid names each server with its phase.
const tile = () => screen.getByTitle(/^lobby · /).getAttribute("title");
const phase = (p: string) => `lobby · ${i18next.t(`servers:phase_${p}`)}`;
it("rereads the servers on its own, and keeps the last read when a reread fails", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
calls.myServers.mockResolvedValue([{ ...lobby, phase: "Stopped", desiredState: "Stopped", playersOnline: 0 }]);
renderDashboard();
await screen.findByTitle(/^lobby · /);
expect(tile()).toBe(phase("stopped"));
calls.myServers.mockResolvedValue([lobby]);
await act(() => vi.advanceTimersByTimeAsync(9_000));
expect(calls.myServers).toHaveBeenCalledTimes(1);
await act(() => vi.advanceTimersByTimeAsync(1_000));
expect(calls.myServers).toHaveBeenCalledTimes(2);
await waitFor(() => expect(tile()).toBe(phase("running")));
calls.myServers.mockRejectedValue(failure("servers unavailable"));
await act(() => vi.advanceTimersByTimeAsync(10_000));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("servers unavailable");
expect(tile()).toBe(phase("running"));
});
it("counts a server just woken as starting", async () => {
calls.myServers.mockResolvedValue([{ ...lobby, phase: "Stopped", desiredState: "Running", playersOnline: 0 }]);
renderDashboard();
await screen.findByTitle(/^lobby · /);
expect(tile()).toBe(phase("starting"));
});
});
+30 -12
View File
@@ -19,13 +19,14 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loading, ErrorState } from "@/components/States"; import { Loading, ErrorState, RefreshError } from "@/components/States";
import { shownPhase } from "@/components/PhaseBadge";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { ErrorBoundary } from "@/components/ErrorBoundary"; import { ErrorBoundary } from "@/components/ErrorBoundary";
import { FleetGrid } from "@/components/FleetGrid"; import { FleetGrid } from "@/components/FleetGrid";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync, useConfig, type AsyncState } from "@/lib/hooks"; import { useAsync, useConfig, usePolling, type AsyncState } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { lazyWithReload } from "@/lib/chunk"; import { lazyWithReload } from "@/lib/chunk";
import { webglAvailable } from "@/lib/webgl"; import { webglAvailable } from "@/lib/webgl";
@@ -39,25 +40,39 @@ const VoxelFleet = lazyWithReload(() =>
import("@/components/VoxelFleet").then((m) => ({ default: m.VoxelFleet })), import("@/components/VoxelFleet").then((m) => ({ default: m.VoxelFleet })),
); );
// The dashboard follows the fleet the way the server list does.
const SERVERS_POLL_MS = 10_000;
export function Dashboard() { export function Dashboard() {
const cfg = useConfig(); const cfg = useConfig();
const { isAdmin } = useTier(); const { isAdmin } = useTier();
const servers = useAsync(() => api.myServers(), []); const servers = useAsync(() => api.myServers(), []);
usePolling(servers.reload, SERVERS_POLL_MS);
const link = useAsync(() => api.linkStatus(), []); const link = useAsync(() => api.linkStatus(), []);
// Only admins may list the image whitelist (a user would get a 403). // Only admins may list the image whitelist (a user would get a 403).
const images = useAsync(() => (isAdmin ? api.listImages() : Promise.resolve([])), [isAdmin]); const images = useAsync(() => (isAdmin ? api.listImages() : Promise.resolve([])), [isAdmin]);
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
// A server just woken or stopped counts, colours and pulses as where it is
// heading, the same as its badge in the list.
const list = useMemo(
() =>
(servers.data ?? []).map((s) => ({
...s,
phase: shownPhase({ phase: s.phase ?? "Unknown", desiredState: s.desiredState }),
})),
[servers.data],
);
const counts = useMemo(() => { const counts = useMemo(() => {
const list = servers.data ?? [];
const by = (p: Phase) => list.filter((s) => s.phase === p).length; const by = (p: Phase) => list.filter((s) => s.phase === p).length;
return { return {
total: list.length, total: list.length,
running: by("Running"), running: by("Running"),
players: list.reduce((n, s) => n + (s.playersOnline ?? 0), 0), players: list.reduce((n, s) => n + (s.playersOnline ?? 0), 0),
}; };
}, [servers.data]); }, [list]);
// The page stands on the server list alone. Link status and the image // The page stands on the server list alone. Link status and the image
// counts are side cards: when they fail only their card degrades, and each // counts are side cards: when they fail only their card degrades, and each
@@ -68,18 +83,21 @@ export function Dashboard() {
{servers.loading && !servers.data ? ( {servers.loading && !servers.data ? (
<Loading /> <Loading />
) : servers.error ? ( ) : servers.error && !servers.data ? (
<ErrorState error={servers.error} onRetry={servers.reload} /> <ErrorState error={servers.error} onRetry={servers.reload} />
) : !cfg ? ( ) : !cfg ? (
<Loading /> <Loading />
) : ( ) : (
<FleetView <>
servers={servers.data ?? []} {servers.error && <RefreshError error={servers.error} className="mb-4" />}
counts={counts} <FleetView
link={link} servers={list}
images={images.error ? null : images.data} counts={counts}
isAdmin={isAdmin} link={link}
/> images={images.error ? null : images.data}
isAdmin={isAdmin}
/>
</>
)} )}
</> </>
); );
+59 -2
View File
@@ -1,9 +1,10 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServerConsole } from "./ServerConsole"; import { ServerConsole } from "./ServerConsole";
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn() })); const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn() }));
vi.mock("@/lib/tier", () => ({ vi.mock("@/lib/tier", () => ({
@@ -94,3 +95,59 @@ describe("ServerConsole edit dialog", () => {
expect((screen.getByLabelText("CPU Limit") as HTMLInputElement).value).toBe("2"); expect((screen.getByLabelText("CPU Limit") as HTMLInputElement).value).toBe("2");
}); });
}); });
describe("ServerConsole following the server", () => {
afterEach(() => {
vi.useRealTimers();
});
const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
it("picks up a server woken elsewhere without a reload", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
renderConsole();
expect(await screen.findByText("Server is asleep")).toBeTruthy();
expect(screen.getByRole("button", { name: "Wake" })).toBeTruthy();
// Nothing is due, so it rereads at the slow pace.
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
await advance(STATUS_POLL_SLOW_MS - 1_000);
expect(calls.status).toHaveBeenCalledTimes(1);
await advance(1_000);
expect(await screen.findByTestId("log-stream")).toBeTruthy();
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
expect(screen.queryByText("Server is asleep")).toBeNull();
});
it("shows a wake at once and rereads fast until the pod is up", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Running" }));
renderConsole();
expect(await screen.findByText("Server is starting")).toBeTruthy();
expect(screen.getByText("Starting")).toBeTruthy();
expect(screen.queryByText("Stopped")).toBeNull();
// Already asked to run: a second Wake would only be refused; Stop is the way out.
expect(screen.queryByRole("button", { name: "Wake" })).toBeNull();
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
calls.status.mockResolvedValue(status({ phase: "Starting", desiredState: "Running" }));
await advance(STATUS_POLL_FAST_MS);
expect(calls.status).toHaveBeenCalledTimes(2);
expect(await screen.findByTestId("log-stream")).toBeTruthy();
});
it("keeps the console when a reread fails, and says the status may be old", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
renderConsole();
expect(await screen.findByTestId("log-stream")).toBeTruthy();
calls.status.mockRejectedValue({ status: 409, code: "test_failure", message: "status backend down" });
await advance(STATUS_POLL_SLOW_MS);
await waitFor(() => expect(screen.getByText(/Couldn't refresh/)).toBeTruthy());
expect(screen.getByText(/status backend down/)).toBeTruthy();
expect(screen.getByTestId("log-stream")).toBeTruthy();
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
});
});
+32 -10
View File
@@ -4,12 +4,12 @@ import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archiv
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { BackLink } from "@/components/BackLink"; import { BackLink } from "@/components/BackLink";
import { MAX_AUTO_RESTARTS, PhaseBadge, startFailure, type StartFailure } from "@/components/PhaseBadge"; import { MAX_AUTO_RESTARTS, PhaseBadge, pendingPower, shownPhase, startFailure, type StartFailure } from "@/components/PhaseBadge";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { LogConsole } from "@/components/LogConsole"; import { LogConsole } from "@/components/LogConsole";
import { Loading, ErrorState } from "@/components/States"; import { Loading, ErrorState, RefreshError } from "@/components/States";
import { api, consoleStreamURL, humanizeError } from "@/lib/api"; import { api, consoleStreamURL, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS, useAsync, useConfig, usePolling } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { canManage } from "@/lib/ownership"; import { canManage } from "@/lib/ownership";
import { joinAddress } from "@/lib/config"; import { joinAddress } from "@/lib/config";
@@ -17,6 +17,7 @@ import { CopyAddress } from "@/components/CopyAddress";
import type { Phase, AutostartPolicy } from "@/lib/types"; import type { Phase, AutostartPolicy } from "@/lib/types";
import { EditServerDialog } from "@/components/EditServerDialog"; import { EditServerDialog } from "@/components/EditServerDialog";
import { PowerButton } from "@/components/PowerButton"; import { PowerButton } from "@/components/PowerButton";
import { cn } from "@/lib/utils";
import { InlineError } from "@/components/MessageLine"; import { InlineError } from "@/components/MessageLine";
// notStreamingCopy explains why there is no live feed for a phase that has no // notStreamingCopy explains why there is no live feed for a phase that has no
@@ -24,9 +25,17 @@ import { InlineError } from "@/components/MessageLine";
// Stopped/Failed/Stopping each get their own honest line rather than an empty // Stopped/Failed/Stopping each get their own honest line rather than an empty
// console. `default` covers Unknown plus any future phase the backend may emit // console. `default` covers Unknown plus any future phase the backend may emit
// that the panel hasn't modelled yet — the screen stays informative regardless. // that the panel hasn't modelled yet — the screen stays informative regardless.
function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string } { function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string; spin?: boolean } {
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
switch (phase) { switch (phase) {
case "Starting":
// Asked to start with no pod yet: the stream attaches once the pod is up.
return {
icon: Loader2,
title: t("server_waking_title"),
body: t("server_waking_body"),
spin: true,
};
case "Stopped": case "Stopped":
return { return {
icon: Moon, icon: Moon,
@@ -49,10 +58,10 @@ function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; b
} }
function NotStreaming({ phase }: { phase: Phase }) { function NotStreaming({ phase }: { phase: Phase }) {
const { icon: Icon, title, body } = useNotStreamingCopy(phase); const { icon: Icon, title, body, spin } = useNotStreamingCopy(phase);
return ( return (
<div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground"> <div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground">
<Icon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70" /> <Icon className={cn("mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70", spin && "animate-spin")} />
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium text-foreground">{title}</p> <p className="font-medium text-foreground">{title}</p>
<p>{body}</p> <p>{body}</p>
@@ -218,6 +227,17 @@ export function ServerConsole() {
() => api.status(name), () => api.status(name),
[name], [name],
); );
// The header, the power button and whether the stream attaches all follow the
// status, so it is reread: fast while the server is on its way somewhere (a
// wake or stop just sent, a pod starting, an automatic retry due), slow while
// nothing is due, which still catches an idle stop or a wake from the lobby.
const moving =
!!data &&
(data.phase === "Starting" ||
data.phase === "Stopping" ||
pendingPower(data) !== null ||
startFailure(data) === "retrying");
usePolling(reload, moving ? STATUS_POLL_FAST_MS : STATUS_POLL_SLOW_MS);
const { data: mine } = useAsync( const { data: mine } = useAsync(
() => (isAdmin ? Promise.resolve([]) : api.myServers()), () => (isAdmin ? Promise.resolve([]) : api.myServers()),
[isAdmin, name], [isAdmin, name],
@@ -242,20 +262,22 @@ export function ServerConsole() {
{loading && !data ? ( {loading && !data ? (
<Loading /> <Loading />
) : error ? ( ) : error && !data ? (
<ErrorState error={error} onRetry={reload} /> <ErrorState error={error} onRetry={reload} />
) : data ? ( ) : data ? (
<> <>
{error && <RefreshError error={error} />}
<PageHeader <PageHeader
icon={Terminal} icon={Terminal}
title={data.displayName || data.name} title={data.displayName || data.name}
subtitle={cfg ? <CopyAddress address={joinAddress(data.subdomain, cfg)} /> : undefined} subtitle={cfg ? <CopyAddress address={joinAddress(data.subdomain, cfg)} /> : undefined}
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PhaseBadge phase={data.phase} failure={failure} autoRestarts={data.autoRestarts} /> <PhaseBadge phase={shownPhase(data)} failure={failure} autoRestarts={data.autoRestarts} />
<PowerButton <PowerButton
name={name} name={name}
live={data.phase === "Running" || data.phase === "Starting" || data.phase === "Stopping"} phase={data.phase}
desiredState={data.desiredState}
failed={failure !== null} failed={failure !== null}
playersOnline={data.playersOnline} playersOnline={data.playersOnline}
playerCountUnknown={data.playerCountUnknown} playerCountUnknown={data.playerCountUnknown}
@@ -273,7 +295,7 @@ export function ServerConsole() {
<CardContent className="p-0 flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black"> <CardContent className="p-0 flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
{!streamable ? ( {!streamable ? (
<div className="flex-1 flex flex-col justify-center p-6 bg-black"> <div className="flex-1 flex flex-col justify-center p-6 bg-black">
<NotStreaming phase={data.phase} /> <NotStreaming phase={shownPhase(data)} />
</div> </div>
) : cfg ? ( ) : cfg ? (
<div className="flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black"> <div className="flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
+38 -4
View File
@@ -1,12 +1,13 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
import { ServerFiles } from "./ServerFiles"; import { ServerFiles } from "./ServerFiles";
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn() })); const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn(), status: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => { vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>(); const actual = await importOriginal<typeof import("@/lib/api")>();
@@ -14,7 +15,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
...actual, ...actual,
api: { api: {
...actual.api, ...actual.api,
status: () => Promise.resolve({ name: "lobby", subdomain: "lobby", phase: "Stopped", ready: false }), status: mocks.status,
listServerFiles: () => listServerFiles: () =>
Promise.resolve({ Promise.resolve({
path: "", path: "",
@@ -43,8 +44,15 @@ async function openEditor() {
return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement }; return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
} }
const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredState: "Stopped", ready: false };
beforeEach(() => { beforeEach(() => {
mocks.writeServerFile.mockReset(); mocks.writeServerFile.mockReset();
mocks.status.mockReset();
mocks.status.mockResolvedValue(stopped);
});
afterEach(() => {
vi.useRealTimers();
}); });
describe("ServerFiles editor", () => { describe("ServerFiles editor", () => {
@@ -93,3 +101,29 @@ describe("ServerFiles editor", () => {
expect(dirty.defaultPrevented).toBe(true); expect(dirty.defaultPrevented).toBe(true);
}); });
}); });
describe("ServerFiles on a running server", () => {
it("lists the files once the server has stopped, without a reload", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mocks.status.mockResolvedValue({ ...stopped, phase: "Running", desiredState: "Running", ready: true });
render(
<MemoryRouter initialEntries={["/servers/lobby/files"]}>
<Routes>
<Route path="/servers/:name/files" element={<ServerFiles />} />
</Routes>
</MemoryRouter>,
);
expect(await screen.findByText(t("files:stopped_required_title"))).toBeTruthy();
expect(screen.queryByText("server.properties")).toBeNull();
mocks.status.mockResolvedValue(stopped);
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
expect(await screen.findByText("server.properties")).toBeTruthy();
expect(screen.queryByText(t("files:stopped_required_title"))).toBeNull();
// Stopped is what it waited for: nothing more to reread.
const reads = mocks.status.mock.calls.length;
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS * 3));
expect(mocks.status.mock.calls.length).toBe(reads);
});
});
+4 -9
View File
@@ -25,11 +25,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { PhaseBadge } from "@/components/PhaseBadge"; import { PhaseBadge, shownPhase } from "@/components/PhaseBadge";
import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States"; import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync, useUnsavedGuard } from "@/lib/hooks"; import { STATUS_POLL_FAST_MS, useAsync, usePolling, useUnsavedGuard } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { canManage, ownershipPending } from "@/lib/ownership"; import { canManage, ownershipPending } from "@/lib/ownership";
import { formatBytes, formatRelative } from "@/lib/format"; import { formatBytes, formatRelative } from "@/lib/format";
@@ -137,12 +137,7 @@ export function ServerFiles() {
// While the server is not stopped, poll the phase so the first successful // While the server is not stopped, poll the phase so the first successful
// stop flips the page from the notice to the listing without a manual reload. // stop flips the page from the notice to the listing without a manual reload.
const reloadStatus = statusQ.reload; usePolling(statusQ.reload, stopped ? null : STATUS_POLL_FAST_MS);
useEffect(() => {
if (stopped) return;
const id = setInterval(reloadStatus, 4000);
return () => clearInterval(id);
}, [stopped, reloadStatus]);
// Editor state. `editable` false marks a binary file (rendered read-only). // Editor state. `editable` false marks a binary file (rendered read-only).
// `sha256` is the hash the read returned: every save sends it back, so a file // `sha256` is the hash the read returned: every save sends it back, so a file
@@ -297,7 +292,7 @@ export function ServerFiles() {
icon={FolderOpen} icon={FolderOpen}
title={statusQ.data.displayName || statusQ.data.name} title={statusQ.data.displayName || statusQ.data.name}
subtitle={t("title")} subtitle={t("title")}
actions={<PhaseBadge phase={statusQ.data.phase} />} actions={<PhaseBadge phase={shownPhase(statusQ.data)} />}
className="mb-6" className="mb-6"
/> />
); );
+34 -1
View File
@@ -1,10 +1,11 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
import { ServerLuckPerms } from "./ServerLuckPerms"; import { ServerLuckPerms } from "./ServerLuckPerms";
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
const calls = vi.hoisted(() => ({ const calls = vi.hoisted(() => ({
status: vi.fn(), status: vi.fn(),
@@ -39,6 +40,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.useRealTimers();
return i18next.changeLanguage("en-US"); return i18next.changeLanguage("en-US");
}); });
@@ -73,3 +75,34 @@ describe("ServerLuckPerms without LuckPerms", () => {
expect(screen.queryByText(/success/i)).toBeNull(); expect(screen.queryByText(/success/i)).toBeNull();
}); });
}); });
describe("ServerLuckPerms on a server that is down", () => {
it("switches over once the server is up, and reads who is online then", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Stopped", desiredState: "Stopped" });
renderPage();
expect(await screen.findByText("Server is asleep")).toBeTruthy();
expect(calls.accessPlayers).not.toHaveBeenCalled();
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Running", desiredState: "Running" });
calls.accessPlayers.mockResolvedValue({ name: "lobby", online: 1, max: 20, players: ["Alex"], output: "" });
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
expect(await screen.findByRole("button", { name: /Alex/ })).toBeTruthy();
expect(calls.accessPlayers).toHaveBeenCalledWith("lobby");
expect(screen.queryByText("Server is asleep")).toBeNull();
});
it("shows a server woken elsewhere as starting", async () => {
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Stopped", desiredState: "Running" });
renderPage();
expect(await screen.findByText("Server is starting")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Wake" })).toBeNull();
});
it("says a server asked to stop is shutting down, before its pod is gone", async () => {
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Running", desiredState: "Stopped" });
renderPage();
expect(await screen.findByText("Server is shutting down")).toBeTruthy();
expect(screen.queryByPlaceholderText("Steve")).toBeNull();
});
});
+26 -11
View File
@@ -15,14 +15,13 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { PhaseBadge } from "@/components/PhaseBadge"; import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States"; import { Loading, ErrorState, NotYours, NotRunning, RefreshError } from "@/components/States";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { STATUS_POLL_SLOW_MS, STATUS_POLL_FAST_MS, useAsync, usePolling } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { canManage, ownershipPending } from "@/lib/ownership"; import { canManage, ownershipPending } from "@/lib/ownership";
import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MC_NAME = /^[A-Za-z0-9_]{1,16}$/; const MC_NAME = /^[A-Za-z0-9_]{1,16}$/;
@@ -61,6 +60,9 @@ export function ServerLuckPerms() {
// Server Status // Server Status
const { data, error, loading, reload } = useAsync(() => api.status(name), [name]); const { data, error, loading, reload } = useAsync(() => api.status(name), [name]);
// The poll is what switches this page over once a wake lands, and back to
// NotRunning when the server stops (idle stop, the console, a player's timeout).
usePolling(reload, data && shownPhase(data) === "Running" ? STATUS_POLL_SLOW_MS : STATUS_POLL_FAST_MS);
// Ownership verification // Ownership verification
const { const {
@@ -72,10 +74,12 @@ export function ServerLuckPerms() {
[isAdmin, name] [isAdmin, name]
); );
// Online Players // Online Players, read once the server is running: a page opened on a sleeping
// server fills the list when the poll sees it come up.
const running = data?.phase === "Running";
const { data: playersData, loading: playersLoading } = useAsync( const { data: playersData, loading: playersLoading } = useAsync(
() => api.accessPlayers(name), () => (running ? api.accessPlayers(name) : Promise.resolve(null)),
[name] [name, running]
); );
const onlinePlayers = playersData?.players ?? []; const onlinePlayers = playersData?.players ?? [];
@@ -290,7 +294,7 @@ export function ServerLuckPerms() {
</> </>
); );
} }
if (error) { if (error && !data) {
return ( return (
<> <>
{back} {back}
@@ -302,14 +306,15 @@ export function ServerLuckPerms() {
const pending = ownershipPending(tierLoading, isAdmin, mine, mineError); const pending = ownershipPending(tierLoading, isAdmin, mine, mineError);
const owned = canManage(isAdmin, mine, name); const owned = canManage(isAdmin, mine, name);
const phase: Phase = data.phase; const phase = shownPhase(data);
const failure = startFailure(data);
const header = ( const header = (
<PageHeader <PageHeader
icon={Shield} icon={Shield}
title={data.displayName || data.name} title={data.displayName || data.name}
subtitle={t("luckperms_desc")} subtitle={t("luckperms_desc")}
actions={<PhaseBadge phase={phase} />} actions={<PhaseBadge phase={phase} failure={failure} autoRestarts={data.autoRestarts} />}
className="mb-6" className="mb-6"
/> />
); );
@@ -318,6 +323,7 @@ export function ServerLuckPerms() {
<> <>
{back} {back}
{header} {header}
{error && <RefreshError error={error} className="mb-4" />}
{pending ? ( {pending ? (
<Loading /> <Loading />
) : mineError ? ( ) : mineError ? (
@@ -325,7 +331,16 @@ export function ServerLuckPerms() {
) : !owned ? ( ) : !owned ? (
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} /> <NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
) : phase !== "Running" ? ( ) : phase !== "Running" ? (
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} serverName={name} onWoken={reload} /> <NotRunning
title={t("players_not_running_title")}
body={t("players_not_running_body")}
serverName={name}
phase={data.phase}
desiredState={data.desiredState}
failure={failure}
autoRestarts={data.autoRestarts}
onWoken={reload}
/>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{/* Left Directory Sidebar: Single card for Search + Online Players */} {/* Left Directory Sidebar: Single card for Search + Online Players */}
+100
View File
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServerPlayers } from "./ServerPlayers";
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn() }));
vi.mock("@/lib/tier", () => ({
useTier: () => ({
loading: false,
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
isAdmin: true,
isOwner: false,
}),
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
// Each section reads over RCON on its own; here it only matters whether the page shows them.
vi.mock("@/components/players/OnlineSection", () => ({ OnlineSection: () => <div data-testid="online" /> }));
vi.mock("@/components/players/WhitelistSection", () => ({ WhitelistSection: () => <div /> }));
vi.mock("@/components/players/BansSection", () => ({ BansSection: () => <div /> }));
const status = (over: Record<string, unknown>) => ({ name: "lobby", subdomain: "lobby", ready: false, ...over });
beforeEach(() => {
calls.status.mockReset();
calls.myServers.mockReset();
calls.myServers.mockResolvedValue([]);
});
afterEach(() => {
vi.useRealTimers();
});
function renderPage() {
render(
<MemoryRouter initialEntries={["/servers/lobby/players"]}>
<Routes>
<Route path="/servers/:name/players" element={<ServerPlayers />} />
</Routes>
</MemoryRouter>,
);
}
describe("ServerPlayers following the server", () => {
it("switches over once the server is up, and back when it stops", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
renderPage();
expect(await screen.findByText("Server is asleep")).toBeTruthy();
// Waiting on the server: the fast pace.
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
expect(await screen.findByTestId("online")).toBeTruthy();
// Running: the slow pace, which still sees an idle stop.
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
const reads = calls.status.mock.calls.length;
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
expect(calls.status.mock.calls.length).toBe(reads);
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_SLOW_MS - STATUS_POLL_FAST_MS));
expect(await screen.findByText("Server is asleep")).toBeTruthy();
expect(screen.queryByTestId("online")).toBeNull();
});
it("keeps the page when a reread fails, and says the status may be old", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
renderPage();
expect(await screen.findByTestId("online")).toBeTruthy();
calls.status.mockRejectedValue({ status: 409, code: "test_failure", message: "status backend down" });
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_SLOW_MS));
expect((await screen.findByRole("alert")).textContent).toContain("status backend down");
expect(screen.getByTestId("online")).toBeTruthy();
});
});
describe("ServerPlayers on a server asked to move", () => {
it("says a woken server is starting and one asked to stop is shutting down", async () => {
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Running" }));
const first = render(
<MemoryRouter initialEntries={["/servers/lobby/players"]}>
<Routes>
<Route path="/servers/:name/players" element={<ServerPlayers />} />
</Routes>
</MemoryRouter>,
);
expect(await screen.findByText("Server is starting")).toBeTruthy();
first.unmount();
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Stopped", ready: true }));
renderPage();
expect(await screen.findByText("Server is shutting down")).toBeTruthy();
expect(screen.queryByTestId("online")).toBeNull();
});
});
+21 -8
View File
@@ -2,17 +2,16 @@ import { useParams } from "react-router-dom";
import { Users } from "lucide-react"; import { Users } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink"; import { BackLink } from "@/components/BackLink";
import { PhaseBadge } from "@/components/PhaseBadge"; import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States"; import { Loading, ErrorState, NotYours, NotRunning, RefreshError } from "@/components/States";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { OnlineSection } from "@/components/players/OnlineSection"; import { OnlineSection } from "@/components/players/OnlineSection";
import { WhitelistSection } from "@/components/players/WhitelistSection"; import { WhitelistSection } from "@/components/players/WhitelistSection";
import { BansSection } from "@/components/players/BansSection"; import { BansSection } from "@/components/players/BansSection";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { STATUS_POLL_SLOW_MS, STATUS_POLL_FAST_MS, useAsync, usePolling } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { canManage, ownershipPending } from "@/lib/ownership"; import { canManage, ownershipPending } from "@/lib/ownership";
import type { Phase } from "@/lib/types";
/** ServerPlayers is the per-server player-management subpage (/servers/:name/players): /** ServerPlayers is the per-server player-management subpage (/servers/:name/players):
* whitelist today, online roster and bans as they land. It owns its own gating — * whitelist today, online roster and bans as they land. It owns its own gating —
@@ -23,6 +22,9 @@ export function ServerPlayers() {
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
const { isAdmin, loading: tierLoading } = useTier(); const { isAdmin, loading: tierLoading } = useTier();
const { data, error, loading, reload } = useAsync(() => api.status(name), [name]); const { data, error, loading, reload } = useAsync(() => api.status(name), [name]);
// The poll is what switches this page over once a wake lands, and back to
// NotRunning when the server stops (idle stop, the console, a player's timeout).
usePolling(reload, data && shownPhase(data) === "Running" ? STATUS_POLL_SLOW_MS : STATUS_POLL_FAST_MS);
const { const {
data: mine, data: mine,
error: mineError, error: mineError,
@@ -44,7 +46,7 @@ export function ServerPlayers() {
</> </>
); );
} }
if (error) { if (error && !data) {
return ( return (
<> <>
{back} {back}
@@ -62,14 +64,15 @@ export function ServerPlayers() {
// NotYours, which would wrongly tell an owner the server isn't theirs on a blip. // NotYours, which would wrongly tell an owner the server isn't theirs on a blip.
const pending = ownershipPending(tierLoading, isAdmin, mine, mineError); const pending = ownershipPending(tierLoading, isAdmin, mine, mineError);
const owned = canManage(isAdmin, mine, name); const owned = canManage(isAdmin, mine, name);
const phase: Phase = data.phase; const phase = shownPhase(data);
const failure = startFailure(data);
const header = ( const header = (
<PageHeader <PageHeader
icon={Users} icon={Users}
title={data.displayName || data.name} title={data.displayName || data.name}
subtitle={t("players_title")} subtitle={t("players_title")}
actions={<PhaseBadge phase={phase} />} actions={<PhaseBadge phase={phase} failure={failure} autoRestarts={data.autoRestarts} />}
/> />
); );
@@ -77,6 +80,7 @@ export function ServerPlayers() {
<> <>
{back} {back}
{header} {header}
{error && <RefreshError error={error} className="mb-4" />}
{pending ? ( {pending ? (
<Loading /> <Loading />
) : mineError ? ( ) : mineError ? (
@@ -84,7 +88,16 @@ export function ServerPlayers() {
) : !owned ? ( ) : !owned ? (
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} /> <NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
) : phase !== "Running" ? ( ) : phase !== "Running" ? (
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} serverName={name} onWoken={reload} /> <NotRunning
title={t("players_not_running_title")}
body={t("players_not_running_body")}
serverName={name}
phase={data.phase}
desiredState={data.desiredState}
failure={failure}
autoRestarts={data.autoRestarts}
onWoken={reload}
/>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Ordered as a who-may-be-here gradient: who is on right now → who may {/* Ordered as a who-may-be-here gradient: who is on right now → who may
@@ -163,3 +163,32 @@ describe("ServersPage failed starts", () => {
expect(flaky.getByRole("button", { name: /Retry start/ })).toBeTruthy(); expect(flaky.getByRole("button", { name: /Retry start/ })).toBeTruthy();
}); });
}); });
describe("ServersPage servers asked to move", () => {
it("shows a wake or stop at once, in the badge, the button and the counts", async () => {
calls.fleet.mockResolvedValue([
row("survival", { phase: "Stopped", desiredState: "Running", owned: true }),
row("creative", { phase: "Running", desiredState: "Stopped", ready: true, owned: true }),
]);
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
const survival = await tableRow("survival");
expect(survival.getByText("Starting")).toBeTruthy();
expect(survival.queryByText("Stopped")).toBeNull();
// Stop stays offered: the way out of a start that never comes up.
expect(survival.getByRole("button", { name: "Stop" })).toHaveProperty("disabled", false);
const creative = await tableRow("creative");
expect(creative.getByText("Stopping")).toBeTruthy();
expect(creative.getByRole("button", { name: "Stopping…" })).toHaveProperty("disabled", true);
expect(creative.queryByRole("button", { name: "Stop" })).toBeNull();
// The Running card counts neither: one is not up yet, the other is going down.
const runningCard = screen.getByText("Running", { selector: "div.mt-1" });
expect(runningCard.previousElementSibling?.textContent).toBe("0");
});
});
+13 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import {
Network, Network,
@@ -30,16 +30,16 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PhaseBadge, PHASE_KEY, PHASE_COLOR, startFailure } from "@/components/PhaseBadge"; import { PhaseBadge, PHASE_KEY, PHASE_COLOR, shownPhase, startFailure } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton"; import { PowerButton } from "@/components/PowerButton";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState, RefreshError } from "@/components/States";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { CreateServerDialog } from "@/components/CreateServerDialog"; import { CreateServerDialog } from "@/components/CreateServerDialog";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { CopyAddress } from "@/components/CopyAddress"; import { CopyAddress } from "@/components/CopyAddress";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { useAsync, useConfig, usePolling } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { joinAddress, type RuntimeConfig } from "@/lib/config"; import { joinAddress, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy"; import { matchScore } from "@/lib/fuzzy";
@@ -107,19 +107,7 @@ export function ServersPage() {
const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all"); const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
useEffect(() => { usePolling(reload, REFRESH_MS);
const id = window.setInterval(() => {
if (!document.hidden) reload();
}, REFRESH_MS);
const onVisible = () => {
if (!document.hidden) reload();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearInterval(id);
document.removeEventListener("visibilitychange", onVisible);
};
}, [reload]);
const servers = useMemo<UnifiedServer[]>(() => { const servers = useMemo<UnifiedServer[]>(() => {
if (!data) return []; if (!data) return [];
@@ -177,7 +165,8 @@ export function ServersPage() {
let playersOnline = 0; let playersOnline = 0;
let playersMax = 0; let playersMax = 0;
for (const s of servers) { for (const s of servers) {
if (counts[s.phase as Phase] !== undefined) counts[s.phase as Phase]++; const phase = shownPhase(s);
if (counts[phase] !== undefined) counts[phase]++;
else counts.Unknown++; else counts.Unknown++;
playersOnline += s.playersOnline ?? 0; playersOnline += s.playersOnline ?? 0;
playersMax += s.playersMax ?? 0; playersMax += s.playersMax ?? 0;
@@ -187,7 +176,7 @@ export function ServersPage() {
const visible = useMemo(() => { const visible = useMemo(() => {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const phaseOk = (s: UnifiedServer) => phaseFilter === "all" || s.phase === phaseFilter; const phaseOk = (s: UnifiedServer) => phaseFilter === "all" || shownPhase(s) === phaseFilter;
if (terms.length === 0) return servers.filter(phaseOk); if (terms.length === 0) return servers.filter(phaseOk);
const scored: { s: UnifiedServer; score: number }[] = []; const scored: { s: UnifiedServer; score: number }[] = [];
for (const s of servers) { for (const s of servers) {
@@ -242,12 +231,7 @@ export function ServersPage() {
<Loading /> <Loading />
) : ( ) : (
<> <>
{error && ( {error && <RefreshError error={error} />}
<div role="alert" className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{humanizeError(error)}
</div>
)}
{/* Stats Cards in a full grid row */} {/* Stats Cards in a full grid row */}
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
@@ -468,7 +452,6 @@ function ServerActions({
); );
} }
const live = isLive(server.phase);
return ( return (
<div className={cn("flex flex-col items-end gap-1", className)}> <div className={cn("flex flex-col items-end gap-1", className)}>
<div className="flex flex-wrap items-start justify-end gap-2 xl:flex-nowrap"> <div className="flex flex-wrap items-start justify-end gap-2 xl:flex-nowrap">
@@ -514,7 +497,8 @@ function ServerActions({
<> <>
<PowerButton <PowerButton
name={server.name} name={server.name}
live={live} phase={server.phase}
desiredState={server.desiredState}
failed={startFailure(server) !== null} failed={startFailure(server) !== null}
playersOnline={server.playersOnline} playersOnline={server.playersOnline}
playerCountUnknown={server.playerCountUnknown} playerCountUnknown={server.playerCountUnknown}
@@ -617,7 +601,7 @@ function ServerRow({
<td className="px-4 py-3 align-middle text-left"> <td className="px-4 py-3 align-middle text-left">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ServerName server={server} /> <ServerName server={server} />
<PhaseBadge phase={server.phase} failure={startFailure(server)} autoRestarts={server.autoRestarts} /> <PhaseBadge phase={shownPhase(server)} failure={startFailure(server)} autoRestarts={server.autoRestarts} />
</div> </div>
{address && <CopyAddress address={address} className="md:max-w-[16rem]" />} {address && <CopyAddress address={address} className="md:max-w-[16rem]" />}
</td> </td>
@@ -676,7 +660,7 @@ function ServerMobileCard({
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">
<ServerName server={server} /> <ServerName server={server} />
</div> </div>
<PhaseBadge phase={server.phase} failure={startFailure(server)} autoRestarts={server.autoRestarts} /> <PhaseBadge phase={shownPhase(server)} failure={startFailure(server)} autoRestarts={server.autoRestarts} />
</div> </div>
{/* Its own line: the join address is what a player came for, so it {/* Its own line: the join address is what a player came for, so it
gets the card's full width rather than sharing it with the badge. */} gets the card's full width rather than sharing it with the badge. */}