-
+
+ {icon}
-
{title}
-
{body}
+
{copy.title}
+
{copy.body}
-
+ {!transitional && (
+
+ )}
);
}
diff --git a/panel/src/i18n/resources/en-US/common.json b/panel/src/i18n/resources/en-US/common.json
index 0e54dc9..aab9826 100644
--- a/panel/src/i18n/resources/en-US/common.json
+++ b/panel/src/i18n/resources/en-US/common.json
@@ -37,5 +37,6 @@
"open_menu": "Open menu",
"copy_address": "Copy address {{address}}",
"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."
}
diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json
index 15e0775..663598a 100644
--- a/panel/src/i18n/resources/en-US/servers.json
+++ b/panel/src/i18n/resources/en-US/servers.json
@@ -30,6 +30,10 @@
"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_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_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",
diff --git a/panel/src/i18n/resources/zh-CN/common.json b/panel/src/i18n/resources/zh-CN/common.json
index 5b7dd44..c00c5f9 100644
--- a/panel/src/i18n/resources/zh-CN/common.json
+++ b/panel/src/i18n/resources/zh-CN/common.json
@@ -37,5 +37,6 @@
"open_menu": "打开菜单",
"copy_address": "复制地址 {{address}}",
"address_copied": "已复制",
- "address_copy_failed": "已选中,请用系统菜单复制"
+ "address_copy_failed": "已选中,请用系统菜单复制",
+ "refresh_failed": "刷新失败,下面是上次读到的状态。"
}
diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json
index 1dc68a2..224b7ff 100644
--- a/panel/src/i18n/resources/zh-CN/servers.json
+++ b/panel/src/i18n/resources/zh-CN/servers.json
@@ -30,6 +30,10 @@
"server_asleep_body": "启动后将拉起 Pod,实时控制台会自动连接。",
"server_shutting_down_title": "服务器正在关闭",
"server_shutting_down_body": "Pod 正在终止,实时控制台已断开。关闭完成后将进入休眠。",
+ "server_waking_title": "服务器正在启动",
+ "server_waking_body": "一般需要一两分钟。服务器运行后,这个页面会自动切换过来。",
+ "server_stopping_title": "服务器正在关闭",
+ "server_stopping_body": "关闭完成后可以在这里重新启动。",
"server_failed_title": "服务器启动失败",
"server_failed_body": "自动重试已用完。控制台里是最后一次启动的日志,找到原因修好后点「重试启动」。",
"server_failed_retrying_title": "启动超时,正在自动重试",
diff --git a/panel/src/lib/hooks.test.tsx b/panel/src/lib/hooks.test.tsx
index 98f3f1a..8cfe73e 100644
--- a/panel/src/lib/hooks.test.tsx
+++ b/panel/src/lib/hooks.test.tsx
@@ -1,7 +1,7 @@
// @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 { 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.
function requests() {
@@ -100,3 +100,57 @@ describe("useAsync", () => {
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);
+ });
+});
diff --git a/panel/src/lib/hooks.ts b/panel/src/lib/hooks.ts
index 22448da..346a9d1 100644
--- a/panel/src/lib/hooks.ts
+++ b/panel/src/lib/hooks.ts
@@ -112,3 +112,26 @@ export function useUnsavedGuard(dirty: boolean): void {
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [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]);
+}
diff --git a/panel/src/pages/Dashboard.test.tsx b/panel/src/pages/Dashboard.test.tsx
index 0b0c677..00bb46f 100644
--- a/panel/src/pages/Dashboard.test.tsx
+++ b/panel/src/pages/Dashboard.test.tsx
@@ -1,5 +1,5 @@
// @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 { MemoryRouter } from "react-router-dom";
import i18next from "i18next";
@@ -158,3 +158,41 @@ describe("Dashboard", () => {
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"));
+ });
+});
diff --git a/panel/src/pages/Dashboard.tsx b/panel/src/pages/Dashboard.tsx
index 295fbe2..dbecd78 100644
--- a/panel/src/pages/Dashboard.tsx
+++ b/panel/src/pages/Dashboard.tsx
@@ -19,13 +19,14 @@ import {
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { PageHeader } from "@/components/PageHeader";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { FleetGrid } from "@/components/FleetGrid";
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 { lazyWithReload } from "@/lib/chunk";
import { webglAvailable } from "@/lib/webgl";
@@ -39,25 +40,39 @@ const VoxelFleet = lazyWithReload(() =>
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() {
const cfg = useConfig();
const { isAdmin } = useTier();
const servers = useAsync(() => api.myServers(), []);
+ usePolling(servers.reload, SERVERS_POLL_MS);
const link = useAsync(() => api.linkStatus(), []);
// Only admins may list the image whitelist (a user would get a 403).
const images = useAsync(() => (isAdmin ? api.listImages() : Promise.resolve([])), [isAdmin]);
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 list = servers.data ?? [];
const by = (p: Phase) => list.filter((s) => s.phase === p).length;
return {
total: list.length,
running: by("Running"),
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
// 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.error ? (
+ ) : servers.error && !servers.data ? (
) : !cfg ? (
) : (
-
+ <>
+ {servers.error &&
}
+
+ >
)}
>
);
diff --git a/panel/src/pages/ServerConsole.test.tsx b/panel/src/pages/ServerConsole.test.tsx
index a9f522c..932418f 100644
--- a/panel/src/pages/ServerConsole.test.tsx
+++ b/panel/src/pages/ServerConsole.test.tsx
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, within } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
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() }));
vi.mock("@/lib/tier", () => ({
@@ -94,3 +95,59 @@ describe("ServerConsole edit dialog", () => {
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();
+ });
+});
diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx
index efe8cbe..e50d93b 100644
--- a/panel/src/pages/ServerConsole.tsx
+++ b/panel/src/pages/ServerConsole.tsx
@@ -4,12 +4,12 @@ import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archiv
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
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 { 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 { 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 { canManage } from "@/lib/ownership";
import { joinAddress } from "@/lib/config";
@@ -17,6 +17,7 @@ import { CopyAddress } from "@/components/CopyAddress";
import type { Phase, AutostartPolicy } from "@/lib/types";
import { EditServerDialog } from "@/components/EditServerDialog";
import { PowerButton } from "@/components/PowerButton";
+import { cn } from "@/lib/utils";
import { InlineError } from "@/components/MessageLine";
// 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
// console. `default` covers Unknown plus any future phase the backend may emit
// 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");
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":
return {
icon: Moon,
@@ -49,10 +58,10 @@ function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; b
}
function NotStreaming({ phase }: { phase: Phase }) {
- const { icon: Icon, title, body } = useNotStreamingCopy(phase);
+ const { icon: Icon, title, body, spin } = useNotStreamingCopy(phase);
return (
-
+
{title}
{body}
@@ -218,6 +227,17 @@ export function ServerConsole() {
() => api.status(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(
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
[isAdmin, name],
@@ -242,20 +262,22 @@ export function ServerConsole() {
{loading && !data ? (
- ) : error ? (
+ ) : error && !data ? (
) : data ? (
<>
+ {error &&
}
: undefined}
actions={
-
+
{!streamable ? (
-
+
) : cfg ? (
diff --git a/panel/src/pages/ServerFiles.test.tsx b/panel/src/pages/ServerFiles.test.tsx
index 0675f33..f1ffa46 100644
--- a/panel/src/pages/ServerFiles.test.tsx
+++ b/panel/src/pages/ServerFiles.test.tsx
@@ -1,12 +1,13 @@
// @vitest-environment jsdom
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, within } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
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) => {
const actual = await importOriginal
();
@@ -14,7 +15,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
...actual,
api: {
...actual.api,
- status: () => Promise.resolve({ name: "lobby", subdomain: "lobby", phase: "Stopped", ready: false }),
+ status: mocks.status,
listServerFiles: () =>
Promise.resolve({
path: "",
@@ -43,8 +44,15 @@ async function openEditor() {
return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
}
+const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredState: "Stopped", ready: false };
+
beforeEach(() => {
mocks.writeServerFile.mockReset();
+ mocks.status.mockReset();
+ mocks.status.mockResolvedValue(stopped);
+});
+afterEach(() => {
+ vi.useRealTimers();
});
describe("ServerFiles editor", () => {
@@ -93,3 +101,29 @@ describe("ServerFiles editor", () => {
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(
+
+
+ } />
+
+ ,
+ );
+ 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);
+ });
+});
diff --git a/panel/src/pages/ServerFiles.tsx b/panel/src/pages/ServerFiles.tsx
index 5963e03..c0cca36 100644
--- a/panel/src/pages/ServerFiles.tsx
+++ b/panel/src/pages/ServerFiles.tsx
@@ -25,11 +25,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { PhaseBadge } from "@/components/PhaseBadge";
+import { PhaseBadge, shownPhase } from "@/components/PhaseBadge";
import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
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 { canManage, ownershipPending } from "@/lib/ownership";
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
// stop flips the page from the notice to the listing without a manual reload.
- const reloadStatus = statusQ.reload;
- useEffect(() => {
- if (stopped) return;
- const id = setInterval(reloadStatus, 4000);
- return () => clearInterval(id);
- }, [stopped, reloadStatus]);
+ usePolling(statusQ.reload, stopped ? null : STATUS_POLL_FAST_MS);
// 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
@@ -297,7 +292,7 @@ export function ServerFiles() {
icon={FolderOpen}
title={statusQ.data.displayName || statusQ.data.name}
subtitle={t("title")}
- actions={}
+ actions={}
className="mb-6"
/>
);
diff --git a/panel/src/pages/ServerLuckPerms.test.tsx b/panel/src/pages/ServerLuckPerms.test.tsx
index 3813d21..bf9eb43 100644
--- a/panel/src/pages/ServerLuckPerms.test.tsx
+++ b/panel/src/pages/ServerLuckPerms.test.tsx
@@ -1,10 +1,11 @@
// @vitest-environment jsdom
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 { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { ServerLuckPerms } from "./ServerLuckPerms";
+import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
const calls = vi.hoisted(() => ({
status: vi.fn(),
@@ -39,6 +40,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.restoreAllMocks();
+ vi.useRealTimers();
return i18next.changeLanguage("en-US");
});
@@ -73,3 +75,34 @@ describe("ServerLuckPerms without LuckPerms", () => {
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();
+ });
+});
diff --git a/panel/src/pages/ServerLuckPerms.tsx b/panel/src/pages/ServerLuckPerms.tsx
index 7d1cc37..6bdff46 100644
--- a/panel/src/pages/ServerLuckPerms.tsx
+++ b/panel/src/pages/ServerLuckPerms.tsx
@@ -15,14 +15,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
-import { PhaseBadge } from "@/components/PhaseBadge";
-import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States";
+import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
+import { Loading, ErrorState, NotYours, NotRunning, RefreshError } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
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 { canManage, ownershipPending } from "@/lib/ownership";
-import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils";
const MC_NAME = /^[A-Za-z0-9_]{1,16}$/;
@@ -61,6 +60,9 @@ export function ServerLuckPerms() {
// Server Status
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
const {
@@ -72,10 +74,12 @@ export function ServerLuckPerms() {
[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(
- () => api.accessPlayers(name),
- [name]
+ () => (running ? api.accessPlayers(name) : Promise.resolve(null)),
+ [name, running]
);
const onlinePlayers = playersData?.players ?? [];
@@ -290,7 +294,7 @@ export function ServerLuckPerms() {
>
);
}
- if (error) {
+ if (error && !data) {
return (
<>
{back}
@@ -302,14 +306,15 @@ export function ServerLuckPerms() {
const pending = ownershipPending(tierLoading, isAdmin, mine, mineError);
const owned = canManage(isAdmin, mine, name);
- const phase: Phase = data.phase;
+ const phase = shownPhase(data);
+ const failure = startFailure(data);
const header = (
}
+ actions={}
className="mb-6"
/>
);
@@ -318,6 +323,7 @@ export function ServerLuckPerms() {
<>
{back}
{header}
+ {error && }
{pending ? (
) : mineError ? (
@@ -325,7 +331,16 @@ export function ServerLuckPerms() {
) : !owned ? (
) : phase !== "Running" ? (
-
+
) : (
{/* Left Directory Sidebar: Single card for Search + Online Players */}
diff --git a/panel/src/pages/ServerPlayers.test.tsx b/panel/src/pages/ServerPlayers.test.tsx
new file mode 100644
index 0000000..9b70cb0
--- /dev/null
+++ b/panel/src/pages/ServerPlayers.test.tsx
@@ -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: "admin@example.test", role: "admin" },
+ isAdmin: true,
+ isOwner: false,
+ }),
+}));
+vi.mock("@/lib/api", async (importOriginal) => {
+ const actual = await importOriginal
();
+ 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: () => }));
+vi.mock("@/components/players/WhitelistSection", () => ({ WhitelistSection: () => }));
+vi.mock("@/components/players/BansSection", () => ({ BansSection: () => }));
+
+const status = (over: Record) => ({ name: "lobby", subdomain: "lobby", ready: false, ...over });
+
+beforeEach(() => {
+ calls.status.mockReset();
+ calls.myServers.mockReset();
+ calls.myServers.mockResolvedValue([]);
+});
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+function renderPage() {
+ render(
+
+
+ } />
+
+ ,
+ );
+}
+
+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(
+
+
+ } />
+
+ ,
+ );
+ 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();
+ });
+});
diff --git a/panel/src/pages/ServerPlayers.tsx b/panel/src/pages/ServerPlayers.tsx
index c3093aa..dfdd43e 100644
--- a/panel/src/pages/ServerPlayers.tsx
+++ b/panel/src/pages/ServerPlayers.tsx
@@ -2,17 +2,16 @@ import { useParams } from "react-router-dom";
import { Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
-import { PhaseBadge } from "@/components/PhaseBadge";
-import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States";
+import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
+import { Loading, ErrorState, NotYours, NotRunning, RefreshError } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { OnlineSection } from "@/components/players/OnlineSection";
import { WhitelistSection } from "@/components/players/WhitelistSection";
import { BansSection } from "@/components/players/BansSection";
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 { canManage, ownershipPending } from "@/lib/ownership";
-import type { Phase } from "@/lib/types";
/** 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 —
@@ -23,6 +22,9 @@ export function ServerPlayers() {
const { t } = useTranslation("servers");
const { isAdmin, loading: tierLoading } = useTier();
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 {
data: mine,
error: mineError,
@@ -44,7 +46,7 @@ export function ServerPlayers() {
>
);
}
- if (error) {
+ if (error && !data) {
return (
<>
{back}
@@ -62,14 +64,15 @@ export function ServerPlayers() {
// NotYours, which would wrongly tell an owner the server isn't theirs on a blip.
const pending = ownershipPending(tierLoading, isAdmin, mine, mineError);
const owned = canManage(isAdmin, mine, name);
- const phase: Phase = data.phase;
+ const phase = shownPhase(data);
+ const failure = startFailure(data);
const header = (
}
+ actions={}
/>
);
@@ -77,6 +80,7 @@ export function ServerPlayers() {
<>
{back}
{header}
+ {error && }
{pending ? (
) : mineError ? (
@@ -84,7 +88,16 @@ export function ServerPlayers() {
) : !owned ? (
) : phase !== "Running" ? (
-
+
) : (
{/* Ordered as a who-may-be-here gradient: who is on right now → who may
diff --git a/panel/src/pages/servers/ServersPage.test.tsx b/panel/src/pages/servers/ServersPage.test.tsx
index dc6969a..25cd1dc 100644
--- a/panel/src/pages/servers/ServersPage.test.tsx
+++ b/panel/src/pages/servers/ServersPage.test.tsx
@@ -163,3 +163,32 @@ describe("ServersPage failed starts", () => {
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(
+
+
+ ,
+ );
+
+ 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");
+ });
+});
diff --git a/panel/src/pages/servers/ServersPage.tsx b/panel/src/pages/servers/ServersPage.tsx
index 0246d30..e49ef0c 100644
--- a/panel/src/pages/servers/ServersPage.tsx
+++ b/panel/src/pages/servers/ServersPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
Network,
@@ -30,16 +30,16 @@ import {
SelectContent,
SelectItem,
} 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 { Loading, ErrorState, EmptyState } from "@/components/States";
+import { Loading, ErrorState, EmptyState, RefreshError } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { CreateServerDialog } from "@/components/CreateServerDialog";
import { StatCard } from "@/components/StatCard";
import { CopyAddress } from "@/components/CopyAddress";
import { PageHeader } from "@/components/PageHeader";
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 { joinAddress, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy";
@@ -107,19 +107,7 @@ export function ServersPage() {
const [phaseFilter, setPhaseFilter] = useState
("all");
const [page, setPage] = useState(1);
- useEffect(() => {
- 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]);
+ usePolling(reload, REFRESH_MS);
const servers = useMemo(() => {
if (!data) return [];
@@ -177,7 +165,8 @@ export function ServersPage() {
let playersOnline = 0;
let playersMax = 0;
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++;
playersOnline += s.playersOnline ?? 0;
playersMax += s.playersMax ?? 0;
@@ -187,7 +176,7 @@ export function ServersPage() {
const visible = useMemo(() => {
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);
const scored: { s: UnifiedServer; score: number }[] = [];
for (const s of servers) {
@@ -242,12 +231,7 @@ export function ServersPage() {
) : (
<>
- {error && (
-
-
- {humanizeError(error)}
-
- )}
+ {error && }
{/* Stats Cards in a full grid row */}
@@ -468,7 +452,6 @@ function ServerActions({
);
}
- const live = isLive(server.phase);
return (
@@ -514,7 +497,8 @@ function ServerActions({
<>
{address && }
@@ -676,7 +660,7 @@ function ServerMobileCard({
-
+
{/* 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. */}