feat: implement backend error handling and improve error messaging in Server component

This commit is contained in:
hamster1963
2026-07-05 13:17:36 +08:00
parent d295d549da
commit 056c357b6e
12 changed files with 187 additions and 27 deletions
+11 -8
View File
@@ -25,12 +25,18 @@ const RouteChecker: React.FC = () => {
return <MainApp />;
};
const toError = (error: unknown) => {
if (!error) return null;
return error instanceof Error ? error : new Error(String(error));
};
const MainApp: React.FC = () => {
const { data: settingData, error } = useQuery({
queryKey: ["setting"],
queryFn: () => fetchSetting(),
refetchOnMount: true,
refetchOnWindowFocus: true,
retry: false,
});
const { i18n } = useTranslation();
const { setTheme } = useTheme();
@@ -59,13 +65,7 @@ const MainApp: React.FC = () => {
}
}, [forceTheme, setTheme]);
if (error) {
return <ErrorPage code={500} message={error.message} />;
}
if (!settingData) {
return null;
}
const initialBackendError = !settingData ? toError(error) : null;
if (settingData?.data?.config?.custom_code && !isCustomCodeInjected) {
return null;
@@ -115,7 +115,10 @@ const MainApp: React.FC = () => {
<Header />
<DashCommand />
<Routes>
<Route path="/" element={<Server />} />
<Route
path="/"
element={<Server backendError={initialBackendError} />}
/>
<Route
path="/server/:id"
element={
+1
View File
@@ -12,6 +12,7 @@ const Footer: React.FC = () => {
queryFn: () => fetchSetting(),
refetchOnMount: true,
refetchOnWindowFocus: true,
retry: false,
});
return (
+1
View File
@@ -58,6 +58,7 @@ function Header() {
queryFn: () => fetchSetting(),
refetchOnMount: true,
refetchOnWindowFocus: true,
retry: false,
});
const { lastData, connected } = useWebSocketContext();
+12 -4
View File
@@ -1,7 +1,11 @@
import { hasFlag } from "country-flag-icons";
import getUnicodeFlagIcon from "country-flag-icons/unicode";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
const normalizeCountryCode = (countryCode: string) =>
countryCode.trim().replace(/_/g, "-").toUpperCase();
export default function ServerFlag({
country_code,
className,
@@ -38,14 +42,18 @@ export default function ServerFlag({
checkEmojiSupport();
}, []);
if (!country_code) return null;
const normalizedCountryCode = normalizeCountryCode(country_code || "");
const canRenderSvgFlag = hasFlag(normalizedCountryCode);
const canRenderEmojiFlag = /^[A-Z]{2}$/.test(normalizedCountryCode);
if (!canRenderSvgFlag) return null;
return (
<span className={cn("text-[12px] text-muted-foreground", className)}>
{forceUseSvgFlag || !supportsEmojiFlags ? (
<span className={`fi fi-${country_code}`} />
{forceUseSvgFlag || !supportsEmojiFlags || !canRenderEmojiFlag ? (
<span className={`fi fi-${normalizedCountryCode.toLowerCase()}`} />
) : (
getUnicodeFlagIcon(country_code)
getUnicodeFlagIcon(normalizedCountryCode)
)}
</span>
);
+1
View File
@@ -43,6 +43,7 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchInterval: 10000,
retry: false,
});
const serviceSummaries = useMemo(() => {
+3 -1
View File
@@ -104,7 +104,9 @@
},
"error": {
"pageNotFound": "Page not found",
"backToHome": "Back to home"
"backToHome": "Back to home",
"backendUnavailableTitle": "Backend API unavailable",
"backendUnavailableDescription": "The page is still available, but data cannot be loaded right now."
},
"tabSwitch": {
"Detail": "Detail",
+3 -1
View File
@@ -105,7 +105,9 @@
},
"error": {
"pageNotFound": "页面不存在",
"backToHome": "回到主页"
"backToHome": "回到主页",
"backendUnavailableTitle": "后端 API 无法访问",
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。"
},
"tabSwitch": {
"Detail": "详情",
+3 -1
View File
@@ -98,7 +98,9 @@
},
"error": {
"pageNotFound": "頁面不存在",
"backToHome": "回到主頁"
"backToHome": "回到主頁",
"backendUnavailableTitle": "後端 API 無法存取",
"backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。"
},
"tabSwitch": {
"detail": "詳細資訊",
+44 -3
View File
@@ -43,19 +43,54 @@ const getUsagePercent = (used = 0, total = 0) => {
return (used / total) * 100;
};
export default function Servers() {
const getErrorMessage = (error: unknown) => {
if (!error) return "";
if (error instanceof Error) return error.message;
return String(error);
};
function BackendErrorState({ error }: { error: unknown }) {
const { t } = useTranslation();
const message = getErrorMessage(error);
return (
<div className="flex min-h-96 flex-col items-center justify-center px-4 text-center">
<div className="flex max-w-md flex-col items-center gap-2">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">
{t("error.backendUnavailableTitle")}
</p>
<p className="text-sm text-muted-foreground">
{t("error.backendUnavailableDescription")}
</p>
{message && (
<p className="mt-1 max-w-full break-words rounded-md bg-stone-100 px-2 py-1 text-xs text-stone-500 dark:bg-stone-800 dark:text-stone-400">
{message}
</p>
)}
</div>
</div>
);
}
export default function Servers({
backendError,
}: {
backendError?: Error | null;
}) {
const { t } = useTranslation();
const { sortType, sortOrder, setSortOrder, setSortType } = useSort();
const { data: groupData } = useQuery({
const { data: groupData, error: groupError } = useQuery({
queryKey: ["server-group"],
queryFn: () => fetchServerGroup(),
retry: false,
});
const { data: serviceData } = useQuery({
const { data: serviceData, error: serviceError } = useQuery({
queryKey: ["service"],
queryFn: () => fetchService(),
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchInterval: 10000,
retry: false,
});
const hasServices =
!!serviceData?.data?.services &&
@@ -349,6 +384,12 @@ export default function Servers() {
status,
]);
const currentBackendError = backendError || groupError || serviceError;
if (!nezhaWsData && currentBackendError) {
return <BackendErrorState error={currentBackendError} />;
}
if (!connected && !lastData) {
return (
<div className="flex flex-col items-center min-h-96 justify-center ">
+53 -4
View File
@@ -25,7 +25,12 @@ vi.mock("../components/Header", () => ({
}));
vi.mock("../pages/Server", () => ({
default: () => <div>server-page</div>,
default: ({ backendError }: { backendError?: Error | null }) => (
<div>
<div>server-page</div>
{backendError && <p>{backendError.message}</p>}
</div>
),
}));
vi.mock("../pages/ServerDetail", () => ({
@@ -69,11 +74,14 @@ function renderApp(route = "/") {
window.history.pushState({}, "", route);
const queryClient = createTestQueryClient();
return render(
return {
queryClient,
...render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
),
};
}
describe("App", () => {
@@ -110,6 +118,20 @@ describe("App", () => {
).toBe(true);
});
it("renders the app shell while initial settings are still pending", async () => {
appMocks.fetchSetting.mockImplementation(
() => new Promise(() => undefined),
);
renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
expect(screen.getByText("refresh-toast")).toBeInTheDocument();
expect(screen.getByText("header")).toBeInTheDocument();
expect(screen.getByText("dash-command")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
});
it("injects custom code before showing the app shell", async () => {
appMocks.fetchSetting.mockResolvedValue(
settingResponse("<script>custom</script>"),
@@ -125,14 +147,41 @@ describe("App", () => {
expect(await screen.findByText("server-page")).toBeInTheDocument();
});
it("renders fetch errors through the error page", async () => {
it("renders the app shell when initial settings fetch fails", async () => {
appMocks.fetchSetting.mockRejectedValue(new Error("settings failed"));
renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
expect(screen.getByText("refresh-toast")).toBeInTheDocument();
expect(screen.getByText("header")).toBeInTheDocument();
expect(screen.getByText("dash-command")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
expect(await screen.findByText("settings failed")).toBeInTheDocument();
});
it("keeps rendering with stale settings when a later settings refetch fails", async () => {
let requestCount = 0;
appMocks.fetchSetting.mockImplementation(() => {
requestCount += 1;
return requestCount === 1
? Promise.resolve(settingResponse())
: Promise.reject(new Error("settings failed"));
});
const { queryClient } = renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
await queryClient.refetchQueries({ queryKey: ["setting"] });
await waitFor(() => {
expect(appMocks.fetchSetting.mock.calls.length).toBeGreaterThanOrEqual(2);
});
expect(screen.getByText("server-page")).toBeInTheDocument();
expect(screen.queryByText("settings failed")).not.toBeInTheDocument();
});
it("routes server detail paths through the app router", async () => {
renderApp("/server/42");
@@ -180,6 +180,17 @@ describe("ServerFlag", () => {
expect(container.querySelector(".fi-us")).toBeInTheDocument();
});
it("normalizes SVG flag classes and ignores invalid country codes", () => {
Object.assign(window, { ForceUseSvgFlag: true });
const { container, rerender } = render(<ServerFlag country_code="US" />);
expect(container.querySelector(".fi-us")).toBeInTheDocument();
rerender(<ServerFlag country_code="u" />);
expect(container).toBeEmptyDOMElement();
});
it("uses emoji flags when the canvas probe detects support", async () => {
Object.assign(window, { ForceUseSvgFlag: false });
const originalCreateElement = document.createElement.bind(document);
+41 -2
View File
@@ -91,7 +91,10 @@ function StatusControl() {
function renderServerPage(
websocketValue: Partial<WebSocketContextType>,
{ withStatusControl = false } = {},
{
backendError = null,
withStatusControl = false,
}: { backendError?: Error | null; withStatusControl?: boolean } = {},
) {
const defaultWebsocketValue: WebSocketContextType = {
lastData: null,
@@ -109,7 +112,7 @@ function renderServerPage(
value={{ ...defaultWebsocketValue, ...websocketValue }}
>
{withStatusControl && <StatusControl />}
<Servers />
<Servers backendError={backendError} />
</WebSocketContext.Provider>
</StatusProvider>
</SortProvider>,
@@ -178,6 +181,42 @@ describe("Servers page", () => {
expect(screen.getByText("info.processing")).toBeInTheDocument();
});
it("renders a centered backend error instead of a 500 page", async () => {
renderServerPage(
{
connected: false,
lastData: null,
},
{ backendError: new Error("settings failed") },
);
expect(
screen.getByText("error.backendUnavailableTitle"),
).toBeInTheDocument();
expect(
screen.getByText("error.backendUnavailableDescription"),
).toBeInTheDocument();
expect(screen.getByText("settings failed")).toBeInTheDocument();
expect(
screen.queryByText("info.websocketConnecting"),
).not.toBeInTheDocument();
});
it("shows backend query errors while waiting for websocket data", async () => {
apiMocks.fetchServerGroup.mockRejectedValue(new Error("group failed"));
apiMocks.fetchService.mockRejectedValue(new Error("service failed"));
renderServerPage({
connected: false,
lastData: null,
});
expect(
await screen.findByText("error.backendUnavailableTitle"),
).toBeInTheDocument();
expect(screen.getByText("group failed")).toBeInTheDocument();
});
it("summarizes online and offline servers from websocket data", async () => {
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({