diff --git a/panel/src/components/players/BansSection.tsx b/panel/src/components/players/BansSection.tsx
index b1804e6..f5896a1 100644
--- a/panel/src/components/players/BansSection.tsx
+++ b/panel/src/components/players/BansSection.tsx
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import {
CollapsibleSection,
FeedbackLine,
+ LoadError,
MC_NAME,
PagerFooter,
PlayerField,
@@ -175,7 +176,7 @@ export function BansSection({ name }: { name: string }) {
{t("log_connecting")}
) : error ? (
-
{t("access_ban_load_error")}
+
) : players.length === 0 ? (
{t("access_ban_empty")}
diff --git a/panel/src/components/players/LoadError.test.tsx b/panel/src/components/players/LoadError.test.tsx
new file mode 100644
index 0000000..21c9510
--- /dev/null
+++ b/panel/src/components/players/LoadError.test.tsx
@@ -0,0 +1,44 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import type { ReactElement } from "react";
+import i18next from "i18next";
+import { humanizeError } from "@/lib/api";
+import { OnlineSection } from "./OnlineSection";
+import { WhitelistSection } from "./WhitelistSection";
+import { BansSection } from "./BansSection";
+import { WakeListSection } from "./WakeListSection";
+
+const calls = vi.hoisted(() => ({
+ accessPlayers: vi.fn(),
+ accessWhitelistList: vi.fn(),
+ accessBanList: vi.fn(),
+ serverAllowlist: vi.fn(),
+}));
+vi.mock("@/lib/api", async (importOriginal) => {
+ const actual = await importOriginal
();
+ return { ...actual, api: { ...actual.api, ...calls } };
+});
+
+beforeEach(() => {
+ for (const fn of Object.values(calls)) fn.mockReset();
+});
+
+// A lapsed session and a server mid-restart want different next steps, so a
+// section whose list failed says which one it hit.
+describe("a player list that failed to load", () => {
+ it.each<[string, keyof typeof calls, string, ReactElement]>([
+ ["online players", "accessPlayers", "servers:access_online_load_error", ],
+ ["whitelist", "accessWhitelistList", "servers:access_whitelist_load_error", ],
+ ["ban list", "accessBanList", "servers:access_ban_load_error", ],
+ ["wake list", "serverAllowlist", "servers:wake_list_load_error", ],
+ ])("names why the %s did not load", async (_label, call, key, section) => {
+ const outage = { status: 409, code: "not_running", message: "server is not running" };
+ calls[call].mockRejectedValue(outage);
+ render(section);
+
+ const alert = await screen.findByRole("alert");
+ expect(alert.textContent).toBe(`${i18next.t(key)} ${humanizeError(outage)}`);
+ expect(humanizeError(outage)).not.toBe("");
+ });
+});
diff --git a/panel/src/components/players/OnlineSection.tsx b/panel/src/components/players/OnlineSection.tsx
index 9d46cc3..e2ab7cf 100644
--- a/panel/src/components/players/OnlineSection.tsx
+++ b/panel/src/components/players/OnlineSection.tsx
@@ -8,6 +8,7 @@ import { useAsync } from "@/lib/hooks";
import {
CollapsibleSection,
FeedbackLine,
+ LoadError,
PagerFooter,
rconReply,
SearchBox,
@@ -112,7 +113,7 @@ export function OnlineSection({ name, defaultOpen = true }: { name: string; defa
{t("log_connecting")}
) : error ? (
- {t("access_online_load_error")}
+
) : players.length === 0 ? (
{namesUnavailable ? (
diff --git a/panel/src/components/players/WakeListSection.test.tsx b/panel/src/components/players/WakeListSection.test.tsx
index 0d3b915..43bc253 100644
--- a/panel/src/components/players/WakeListSection.test.tsx
+++ b/panel/src/components/players/WakeListSection.test.tsx
@@ -103,7 +103,9 @@ describe("WakeListSection", () => {
calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" });
render();
- expect((await screen.findByRole("alert")).textContent).toBe("Couldn't load the wake list.");
+ expect((await screen.findByRole("alert")).textContent).toBe(
+ "Couldn't load the wake list. The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
+ );
});
it("finds a player by account name or by UUID once the list is long", async () => {
diff --git a/panel/src/components/players/WakeListSection.tsx b/panel/src/components/players/WakeListSection.tsx
index 4a34642..df129b9 100644
--- a/panel/src/components/players/WakeListSection.tsx
+++ b/panel/src/components/players/WakeListSection.tsx
@@ -8,7 +8,7 @@ import { formatAbsolute, formatRelative } from "@/lib/format";
import { useAsync } from "@/lib/hooks";
import type { AllowlistEntry, AutostartPolicy } from "@/lib/types";
import { cn } from "@/lib/utils";
-import { CollapsibleSection, FeedbackLine, PagerFooter, SearchBox, usePagedNames, type Feedback } from "./shared";
+import { CollapsibleSection, FeedbackLine, LoadError, PagerFooter, SearchBox, usePagedNames, type Feedback } from "./shared";
/** WakeListSection shows who may wake the server while it sleeps under the "wake
* list" autostart policy, and lets the owner take that right away or give it back.
@@ -100,9 +100,7 @@ export function WakeListSection({
{t("log_connecting")}
) : error ? (
-
- {t("wake_list_load_error")}
-
+
) : entries.length === 0 ? (
{t("wake_list_empty")}
diff --git a/panel/src/components/players/WhitelistSection.tsx b/panel/src/components/players/WhitelistSection.tsx
index 2bf27a7..e0dbfb0 100644
--- a/panel/src/components/players/WhitelistSection.tsx
+++ b/panel/src/components/players/WhitelistSection.tsx
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import {
CollapsibleSection,
FeedbackLine,
+ LoadError,
MC_NAME,
PagerFooter,
PlayerField,
@@ -145,7 +146,7 @@ export function WhitelistSection({ name, defaultOpen = false }: { name: string;
{t("log_connecting")}
) : error ? (
- {t("access_whitelist_load_error")}
+
) : players.length === 0 ? (
{t("access_whitelist_empty")}
diff --git a/panel/src/components/players/shared.tsx b/panel/src/components/players/shared.tsx
index 6a832a1..a14665c 100644
--- a/panel/src/components/players/shared.tsx
+++ b/panel/src/components/players/shared.tsx
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import { humanizeError } from "@/lib/api";
import { cn } from "@/lib/utils";
// MC_NAME mirrors the backend's mcNameRe (handlers_access.go): a Minecraft name is
@@ -45,6 +46,17 @@ export function FeedbackLine({ fb }: { fb: Feedback }) {
);
}
+/** LoadError says a section's list failed to load and why: a lapsed session, a
+ * server mid-restart and an RCON refusal each want a different next step, so the
+ * reason is shown with the message. The section header's refresh retries. */
+export function LoadError({ message, error }: { message: string; error: unknown }) {
+ return (
+
+ {message} {humanizeError(error)}
+
+ );
+}
+
/** PlayerField is the shared player-name input: a controlled text box that enforces
* the access charset live (showing the rule only once the user has typed something
* wrong) and fires onEnter so the keyboard-only path works in every section. */