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. */