From 056c357b6ef6f03d59d5762f561ec91b7421fe59 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Sun, 5 Jul 2026 13:17:36 +0800 Subject: [PATCH] feat: implement backend error handling and improve error messaging in Server component --- src/App.tsx | 19 +++--- src/components/Footer.tsx | 1 + src/components/Header.tsx | 1 + src/components/ServerFlag.tsx | 16 +++-- src/components/ServiceTracker.tsx | 1 + src/locales/en/translation.json | 4 +- src/locales/zh-CN/translation.json | 4 +- src/locales/zh-TW/translation.json | 4 +- src/pages/Server.tsx | 47 ++++++++++++++- src/test/app.test.tsx | 63 +++++++++++++++++--- src/test/components/info-components.test.tsx | 11 ++++ src/test/pages/Server.test.tsx | 43 ++++++++++++- 12 files changed, 187 insertions(+), 27 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3240569..c328b13 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,12 +25,18 @@ const RouteChecker: React.FC = () => { return ; }; +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 ; - } - - 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 = () => {
- } /> + } + /> { queryFn: () => fetchSetting(), refetchOnMount: true, refetchOnWindowFocus: true, + retry: false, }); return ( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 8a5db4f..26455cf 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -58,6 +58,7 @@ function Header() { queryFn: () => fetchSetting(), refetchOnMount: true, refetchOnWindowFocus: true, + retry: false, }); const { lastData, connected } = useWebSocketContext(); diff --git a/src/components/ServerFlag.tsx b/src/components/ServerFlag.tsx index 7936a02..8da9e74 100644 --- a/src/components/ServerFlag.tsx +++ b/src/components/ServerFlag.tsx @@ -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 ( - {forceUseSvgFlag || !supportsEmojiFlags ? ( - + {forceUseSvgFlag || !supportsEmojiFlags || !canRenderEmojiFlag ? ( + ) : ( - getUnicodeFlagIcon(country_code) + getUnicodeFlagIcon(normalizedCountryCode) )} ); diff --git a/src/components/ServiceTracker.tsx b/src/components/ServiceTracker.tsx index 0bb7743..5dbd9ea 100644 --- a/src/components/ServiceTracker.tsx +++ b/src/components/ServiceTracker.tsx @@ -43,6 +43,7 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) { refetchOnMount: true, refetchOnWindowFocus: true, refetchInterval: 10000, + retry: false, }); const serviceSummaries = useMemo(() => { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 222d976..e70a163 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 48d735b..6605c2e 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -105,7 +105,9 @@ }, "error": { "pageNotFound": "页面不存在", - "backToHome": "回到主页" + "backToHome": "回到主页", + "backendUnavailableTitle": "后端 API 无法访问", + "backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。" }, "tabSwitch": { "Detail": "详情", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 093d1e4..7a54f10 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -98,7 +98,9 @@ }, "error": { "pageNotFound": "頁面不存在", - "backToHome": "回到主頁" + "backToHome": "回到主頁", + "backendUnavailableTitle": "後端 API 無法存取", + "backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。" }, "tabSwitch": { "detail": "詳細資訊", diff --git a/src/pages/Server.tsx b/src/pages/Server.tsx index d1cebae..c73c895 100644 --- a/src/pages/Server.tsx +++ b/src/pages/Server.tsx @@ -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 ( +
+
+

+ {t("error.backendUnavailableTitle")} +

+

+ {t("error.backendUnavailableDescription")} +

+ {message && ( +

+ {message} +

+ )} +
+
+ ); +} + +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 ; + } + if (!connected && !lastData) { return (
diff --git a/src/test/app.test.tsx b/src/test/app.test.tsx index c036a2e..5f7c474 100644 --- a/src/test/app.test.tsx +++ b/src/test/app.test.tsx @@ -25,7 +25,12 @@ vi.mock("../components/Header", () => ({ })); vi.mock("../pages/Server", () => ({ - default: () =>
server-page
, + default: ({ backendError }: { backendError?: Error | null }) => ( +
+
server-page
+ {backendError &&

{backendError.message}

} +
+ ), })); vi.mock("../pages/ServerDetail", () => ({ @@ -69,11 +74,14 @@ function renderApp(route = "/") { window.history.pushState({}, "", route); const queryClient = createTestQueryClient(); - return render( - - - , - ); + return { + queryClient, + ...render( + + + , + ), + }; } 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(""), @@ -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"); diff --git a/src/test/components/info-components.test.tsx b/src/test/components/info-components.test.tsx index bbed08e..886870b 100644 --- a/src/test/components/info-components.test.tsx +++ b/src/test/components/info-components.test.tsx @@ -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(); + + expect(container.querySelector(".fi-us")).toBeInTheDocument(); + + rerender(); + + 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); diff --git a/src/test/pages/Server.test.tsx b/src/test/pages/Server.test.tsx index fb2a525..f8ca860 100644 --- a/src/test/pages/Server.test.tsx +++ b/src/test/pages/Server.test.tsx @@ -91,7 +91,10 @@ function StatusControl() { function renderServerPage( websocketValue: Partial, - { 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 && } - + , @@ -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({