fix(panel): 玩家页四个名单加载失败时带上具体原因
This commit is contained in:
7 files changed
+67
-8
No files matched your search
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import {
|
import {
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
FeedbackLine,
|
FeedbackLine,
|
||||||
|
LoadError,
|
||||||
MC_NAME,
|
MC_NAME,
|
||||||
PagerFooter,
|
PagerFooter,
|
||||||
PlayerField,
|
PlayerField,
|
||||||
@@ -175,7 +176,7 @@ export function BansSection({ name }: { name: string }) {
|
|||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p role="alert" className="text-xs text-destructive">{t("access_ban_load_error")}</p>
|
<LoadError message={t("access_ban_load_error")} error={error} />
|
||||||
) : players.length === 0 ? (
|
) : players.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">{t("access_ban_empty")}</p>
|
<p className="text-sm text-muted-foreground">{t("access_ban_empty")}</p>
|
||||||
|
|||||||
@@ -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<typeof import("@/lib/api")>();
|
||||||
|
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", <OnlineSection name="lobby" />],
|
||||||
|
["whitelist", "accessWhitelistList", "servers:access_whitelist_load_error", <WhitelistSection name="lobby" />],
|
||||||
|
["ban list", "accessBanList", "servers:access_ban_load_error", <BansSection name="lobby" />],
|
||||||
|
["wake list", "serverAllowlist", "servers:wake_list_load_error", <WakeListSection name="lobby" policy="allowlist" />],
|
||||||
|
])("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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { useAsync } from "@/lib/hooks";
|
|||||||
import {
|
import {
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
FeedbackLine,
|
FeedbackLine,
|
||||||
|
LoadError,
|
||||||
PagerFooter,
|
PagerFooter,
|
||||||
rconReply,
|
rconReply,
|
||||||
SearchBox,
|
SearchBox,
|
||||||
@@ -112,7 +113,7 @@ export function OnlineSection({ name, defaultOpen = true }: { name: string; defa
|
|||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p role="alert" className="text-xs text-destructive">{t("access_online_load_error")}</p>
|
<LoadError message={t("access_online_load_error")} error={error} />
|
||||||
) : players.length === 0 ? (
|
) : players.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||||
{namesUnavailable ? (
|
{namesUnavailable ? (
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ describe("WakeListSection", () => {
|
|||||||
|
|
||||||
calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" });
|
calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" });
|
||||||
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
|
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
|
||||||
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 () => {
|
it("finds a player by account name or by UUID once the list is long", async () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { formatAbsolute, formatRelative } from "@/lib/format";
|
|||||||
import { useAsync } from "@/lib/hooks";
|
import { useAsync } from "@/lib/hooks";
|
||||||
import type { AllowlistEntry, AutostartPolicy } from "@/lib/types";
|
import type { AllowlistEntry, AutostartPolicy } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
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
|
/** 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.
|
* list" autostart policy, and lets the owner take that right away or give it back.
|
||||||
@@ -100,9 +100,7 @@ export function WakeListSection({
|
|||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p role="alert" className="text-xs text-destructive">
|
<LoadError message={t("wake_list_load_error")} error={error} />
|
||||||
{t("wake_list_load_error")}
|
|
||||||
</p>
|
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">{t("wake_list_empty")}</p>
|
<p className="text-sm text-muted-foreground">{t("wake_list_empty")}</p>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import {
|
import {
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
FeedbackLine,
|
FeedbackLine,
|
||||||
|
LoadError,
|
||||||
MC_NAME,
|
MC_NAME,
|
||||||
PagerFooter,
|
PagerFooter,
|
||||||
PlayerField,
|
PlayerField,
|
||||||
@@ -145,7 +146,7 @@ export function WhitelistSection({ name, defaultOpen = false }: { name: string;
|
|||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p role="alert" className="text-xs text-destructive">{t("access_whitelist_load_error")}</p>
|
<LoadError message={t("access_whitelist_load_error")} error={error} />
|
||||||
) : players.length === 0 ? (
|
) : players.length === 0 ? (
|
||||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">{t("access_whitelist_empty")}</p>
|
<p className="text-sm text-muted-foreground">{t("access_whitelist_empty")}</p>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { humanizeError } from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// MC_NAME mirrors the backend's mcNameRe (handlers_access.go): a Minecraft name is
|
// 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 (
|
||||||
|
<p role="alert" className="text-xs text-destructive">
|
||||||
|
{message} {humanizeError(error)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** PlayerField is the shared player-name input: a controlled text box that enforces
|
/** 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
|
* 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. */
|
* wrong) and fires onEnter so the keyboard-only path works in every section. */
|
||||||
|
|||||||
Reference in new issue
Block a user