diff --git a/panel/src/pages/servers/ServersPage.test.tsx b/panel/src/pages/servers/ServersPage.test.tsx
index 37b6dd0..84fb625 100644
--- a/panel/src/pages/servers/ServersPage.test.tsx
+++ b/panel/src/pages/servers/ServersPage.test.tsx
@@ -428,3 +428,41 @@ describe("ServersPage with nothing matching", () => {
expect(await tableOrder()).toEqual(names.slice(0, 20));
});
});
+
+describe("ServersPage refresh button", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("stays still and clickable through the background reread, and spins for its own click", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ calls.fleet.mockResolvedValue([row("survival", {})]);
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render(
+
+
+ ,
+ );
+ await tableOrder();
+ const button = screen.getByRole("button", { name: "Refresh now" }) as HTMLButtonElement;
+ const spinning = () => button.querySelector("svg")?.classList.contains("animate-spin");
+
+ // The background reread hangs; the button does not show it.
+ let finish: (v: FleetServer[]) => void = () => {};
+ calls.fleet.mockReturnValueOnce(new Promise((r) => (finish = r)));
+ await act(() => vi.advanceTimersByTimeAsync(10_000));
+ expect(calls.fleet).toHaveBeenCalledTimes(2);
+ expect(button.disabled).toBe(false);
+ expect(spinning()).toBe(false);
+
+ calls.fleet.mockReturnValueOnce(new Promise((r) => (finish = r)));
+ await user.click(button);
+ expect(calls.fleet).toHaveBeenCalledTimes(3);
+ expect(button.disabled).toBe(true);
+ expect(spinning()).toBe(true);
+
+ await act(async () => finish([row("survival", {})]));
+ expect(button.disabled).toBe(false);
+ expect(spinning()).toBe(false);
+ });
+});
diff --git a/panel/src/pages/servers/ServersPage.tsx b/panel/src/pages/servers/ServersPage.tsx
index a5a7aae..a687602 100644
--- a/panel/src/pages/servers/ServersPage.tsx
+++ b/panel/src/pages/servers/ServersPage.tsx
@@ -148,6 +148,16 @@ export function ServersPage() {
usePolling(reload, REFRESH_MS);
+ // The refresh button answers its own click alone. The background reread every
+ // few seconds leaves it still and clickable; a spin and a disabled button on
+ // each tick read as the page stalling.
+ const [refreshing, setRefreshing] = useState(false);
+ if (refreshing && !loading) setRefreshing(false);
+ const refresh = () => {
+ setRefreshing(true);
+ reload();
+ };
+
const servers = useMemo(() => {
if (!data) return [];
if (isAdmin) {
@@ -253,12 +263,12 @@ export function ServersPage() {
{isAdmin && cfg && }