mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 09:40:14 +00:00
feat: implement backend error handling and improve error messaging in Server component
This commit is contained in:
+11
-8
@@ -25,12 +25,18 @@ const RouteChecker: React.FC = () => {
|
|||||||
return <MainApp />;
|
return <MainApp />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toError = (error: unknown) => {
|
||||||
|
if (!error) return null;
|
||||||
|
return error instanceof Error ? error : new Error(String(error));
|
||||||
|
};
|
||||||
|
|
||||||
const MainApp: React.FC = () => {
|
const MainApp: React.FC = () => {
|
||||||
const { data: settingData, error } = useQuery({
|
const { data: settingData, error } = useQuery({
|
||||||
queryKey: ["setting"],
|
queryKey: ["setting"],
|
||||||
queryFn: () => fetchSetting(),
|
queryFn: () => fetchSetting(),
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
@@ -59,13 +65,7 @@ const MainApp: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [forceTheme, setTheme]);
|
}, [forceTheme, setTheme]);
|
||||||
|
|
||||||
if (error) {
|
const initialBackendError = !settingData ? toError(error) : null;
|
||||||
return <ErrorPage code={500} message={error.message} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!settingData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settingData?.data?.config?.custom_code && !isCustomCodeInjected) {
|
if (settingData?.data?.config?.custom_code && !isCustomCodeInjected) {
|
||||||
return null;
|
return null;
|
||||||
@@ -115,7 +115,10 @@ const MainApp: React.FC = () => {
|
|||||||
<Header />
|
<Header />
|
||||||
<DashCommand />
|
<DashCommand />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Server />} />
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<Server backendError={initialBackendError} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/server/:id"
|
path="/server/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const Footer: React.FC = () => {
|
|||||||
queryFn: () => fetchSetting(),
|
queryFn: () => fetchSetting(),
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function Header() {
|
|||||||
queryFn: () => fetchSetting(),
|
queryFn: () => fetchSetting(),
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastData, connected } = useWebSocketContext();
|
const { lastData, connected } = useWebSocketContext();
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { hasFlag } from "country-flag-icons";
|
||||||
import getUnicodeFlagIcon from "country-flag-icons/unicode";
|
import getUnicodeFlagIcon from "country-flag-icons/unicode";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const normalizeCountryCode = (countryCode: string) =>
|
||||||
|
countryCode.trim().replace(/_/g, "-").toUpperCase();
|
||||||
|
|
||||||
export default function ServerFlag({
|
export default function ServerFlag({
|
||||||
country_code,
|
country_code,
|
||||||
className,
|
className,
|
||||||
@@ -38,14 +42,18 @@ export default function ServerFlag({
|
|||||||
checkEmojiSupport();
|
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 (
|
return (
|
||||||
<span className={cn("text-[12px] text-muted-foreground", className)}>
|
<span className={cn("text-[12px] text-muted-foreground", className)}>
|
||||||
{forceUseSvgFlag || !supportsEmojiFlags ? (
|
{forceUseSvgFlag || !supportsEmojiFlags || !canRenderEmojiFlag ? (
|
||||||
<span className={`fi fi-${country_code}`} />
|
<span className={`fi fi-${normalizedCountryCode.toLowerCase()}`} />
|
||||||
) : (
|
) : (
|
||||||
getUnicodeFlagIcon(country_code)
|
getUnicodeFlagIcon(normalizedCountryCode)
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
|
|||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const serviceSummaries = useMemo(() => {
|
const serviceSummaries = useMemo(() => {
|
||||||
|
|||||||
@@ -104,7 +104,9 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"pageNotFound": "Page not found",
|
"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": {
|
"tabSwitch": {
|
||||||
"Detail": "Detail",
|
"Detail": "Detail",
|
||||||
|
|||||||
@@ -105,7 +105,9 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"pageNotFound": "页面不存在",
|
"pageNotFound": "页面不存在",
|
||||||
"backToHome": "回到主页"
|
"backToHome": "回到主页",
|
||||||
|
"backendUnavailableTitle": "后端 API 无法访问",
|
||||||
|
"backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。"
|
||||||
},
|
},
|
||||||
"tabSwitch": {
|
"tabSwitch": {
|
||||||
"Detail": "详情",
|
"Detail": "详情",
|
||||||
|
|||||||
@@ -98,7 +98,9 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"pageNotFound": "頁面不存在",
|
"pageNotFound": "頁面不存在",
|
||||||
"backToHome": "回到主頁"
|
"backToHome": "回到主頁",
|
||||||
|
"backendUnavailableTitle": "後端 API 無法存取",
|
||||||
|
"backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。"
|
||||||
},
|
},
|
||||||
"tabSwitch": {
|
"tabSwitch": {
|
||||||
"detail": "詳細資訊",
|
"detail": "詳細資訊",
|
||||||
|
|||||||
+44
-3
@@ -43,19 +43,54 @@ const getUsagePercent = (used = 0, total = 0) => {
|
|||||||
return (used / total) * 100;
|
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 { t } = useTranslation();
|
||||||
const { sortType, sortOrder, setSortOrder, setSortType } = useSort();
|
const { sortType, sortOrder, setSortOrder, setSortType } = useSort();
|
||||||
const { data: groupData } = useQuery({
|
const { data: groupData, error: groupError } = useQuery({
|
||||||
queryKey: ["server-group"],
|
queryKey: ["server-group"],
|
||||||
queryFn: () => fetchServerGroup(),
|
queryFn: () => fetchServerGroup(),
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
const { data: serviceData } = useQuery({
|
const { data: serviceData, error: serviceError } = useQuery({
|
||||||
queryKey: ["service"],
|
queryKey: ["service"],
|
||||||
queryFn: () => fetchService(),
|
queryFn: () => fetchService(),
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
const hasServices =
|
const hasServices =
|
||||||
!!serviceData?.data?.services &&
|
!!serviceData?.data?.services &&
|
||||||
@@ -349,6 +384,12 @@ export default function Servers() {
|
|||||||
status,
|
status,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const currentBackendError = backendError || groupError || serviceError;
|
||||||
|
|
||||||
|
if (!nezhaWsData && currentBackendError) {
|
||||||
|
return <BackendErrorState error={currentBackendError} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!connected && !lastData) {
|
if (!connected && !lastData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center min-h-96 justify-center ">
|
<div className="flex flex-col items-center min-h-96 justify-center ">
|
||||||
|
|||||||
+53
-4
@@ -25,7 +25,12 @@ vi.mock("../components/Header", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../pages/Server", () => ({
|
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", () => ({
|
vi.mock("../pages/ServerDetail", () => ({
|
||||||
@@ -69,11 +74,14 @@ function renderApp(route = "/") {
|
|||||||
window.history.pushState({}, "", route);
|
window.history.pushState({}, "", route);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
return render(
|
return {
|
||||||
|
queryClient,
|
||||||
|
...render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
@@ -110,6 +118,20 @@ describe("App", () => {
|
|||||||
).toBe(true);
|
).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 () => {
|
it("injects custom code before showing the app shell", async () => {
|
||||||
appMocks.fetchSetting.mockResolvedValue(
|
appMocks.fetchSetting.mockResolvedValue(
|
||||||
settingResponse("<script>custom</script>"),
|
settingResponse("<script>custom</script>"),
|
||||||
@@ -125,14 +147,41 @@ describe("App", () => {
|
|||||||
expect(await screen.findByText("server-page")).toBeInTheDocument();
|
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"));
|
appMocks.fetchSetting.mockRejectedValue(new Error("settings failed"));
|
||||||
|
|
||||||
renderApp();
|
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();
|
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 () => {
|
it("routes server detail paths through the app router", async () => {
|
||||||
renderApp("/server/42");
|
renderApp("/server/42");
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,17 @@ describe("ServerFlag", () => {
|
|||||||
expect(container.querySelector(".fi-us")).toBeInTheDocument();
|
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 () => {
|
it("uses emoji flags when the canvas probe detects support", async () => {
|
||||||
Object.assign(window, { ForceUseSvgFlag: false });
|
Object.assign(window, { ForceUseSvgFlag: false });
|
||||||
const originalCreateElement = document.createElement.bind(document);
|
const originalCreateElement = document.createElement.bind(document);
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ function StatusControl() {
|
|||||||
|
|
||||||
function renderServerPage(
|
function renderServerPage(
|
||||||
websocketValue: Partial<WebSocketContextType>,
|
websocketValue: Partial<WebSocketContextType>,
|
||||||
{ withStatusControl = false } = {},
|
{
|
||||||
|
backendError = null,
|
||||||
|
withStatusControl = false,
|
||||||
|
}: { backendError?: Error | null; withStatusControl?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const defaultWebsocketValue: WebSocketContextType = {
|
const defaultWebsocketValue: WebSocketContextType = {
|
||||||
lastData: null,
|
lastData: null,
|
||||||
@@ -109,7 +112,7 @@ function renderServerPage(
|
|||||||
value={{ ...defaultWebsocketValue, ...websocketValue }}
|
value={{ ...defaultWebsocketValue, ...websocketValue }}
|
||||||
>
|
>
|
||||||
{withStatusControl && <StatusControl />}
|
{withStatusControl && <StatusControl />}
|
||||||
<Servers />
|
<Servers backendError={backendError} />
|
||||||
</WebSocketContext.Provider>
|
</WebSocketContext.Provider>
|
||||||
</StatusProvider>
|
</StatusProvider>
|
||||||
</SortProvider>,
|
</SortProvider>,
|
||||||
@@ -178,6 +181,42 @@ describe("Servers page", () => {
|
|||||||
expect(screen.getByText("info.processing")).toBeInTheDocument();
|
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 () => {
|
it("summarizes online and offline servers from websocket data", async () => {
|
||||||
const online = createServer({ id: 1, name: "alpha" });
|
const online = createServer({ id: 1, name: "alpha" });
|
||||||
const offline = createServer({
|
const offline = createServer({
|
||||||
|
|||||||
Reference in New Issue
Block a user