瀏覽代碼

fix(ui): exit infinite spinner with a retry card on failed initial load

List pages wrapped content in <Spin spinning={!fetched}> where 'fetched' only flipped true once data arrived. With staleTime: Infinity + retry: 1, a transient network error on first load left the query in a permanent error state and the spinner stuck forever.

Now 'fetched' also settles on query.isError, and a failed load shows a Result error card with a Refresh button that self-heals when the backend returns, mirroring the existing XrayPage pattern. Applied to clients, inbounds, groups, nodes, and the dashboard.

Fixes #4723
MHSanaei 11 小時之前
父節點
當前提交
b9cbc0c1e8

+ 3 - 1
frontend/src/api/queries/useNodesQuery.ts

@@ -76,6 +76,8 @@ export function useNodesQuery() {
     nodes,
     totals,
     loading: query.isFetching,
-    fetched: query.data !== undefined,
+    fetched: query.data !== undefined || query.isError,
+    fetchError: query.error ? (query.error as Error).message : '',
+    refetch: query.refetch,
   };
 }

+ 2 - 1
frontend/src/api/queries/useStatusQuery.ts

@@ -30,7 +30,8 @@ export function useStatusQuery() {
 
   return {
     status,
-    fetched: query.data !== undefined,
+    fetched: query.data !== undefined || query.isError,
+    fetchError: query.error ? (query.error as Error).message : '',
     refresh,
   };
 }

+ 3 - 1
frontend/src/hooks/useClients.ts

@@ -213,7 +213,8 @@ export function useClients() {
   const total = listQuery.data?.total ?? 0;
   const filtered = listQuery.data?.filtered ?? 0;
   const allGroups = listQuery.data?.groups ?? [];
-  const fetched = listQuery.data !== undefined;
+  const fetched = listQuery.data !== undefined || listQuery.isError;
+  const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
   const loading = listQuery.isFetching;
 
   const inbounds = inboundOptionsQuery.data ?? [];
@@ -532,6 +533,7 @@ export function useClients() {
     onlines,
     loading,
     fetched,
+    fetchError,
     subSettings,
     ipLimitEnable,
     tgBotEnable,

+ 10 - 1
frontend/src/pages/clients/ClientsPage.tsx

@@ -13,6 +13,7 @@ import {
   Modal,
   Pagination,
   Popover,
+  Result,
   Row,
   Select,
   Space,
@@ -191,11 +192,12 @@ export default function ClientsPage() {
     summary: serverSummary,
     allGroups,
     setQuery,
-    inbounds, onlines, loading, fetched, subSettings,
+    inbounds, onlines, loading, fetched, fetchError, subSettings,
     ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
     create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
+    refresh,
     hydrate,
   } = useClients();
 
@@ -795,6 +797,13 @@ export default function ClientsPage() {
             <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" loading={loading} onClick={refresh}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
                   <Col span={24}>

+ 10 - 1
frontend/src/pages/groups/GroupsPage.tsx

@@ -10,6 +10,7 @@ import {
   Input,
   Layout,
   Modal,
+  Result,
   Row,
   Space,
   Spin,
@@ -97,7 +98,8 @@ export default function GroupsPage() {
   });
   const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
   const loading = groupsQuery.isFetching;
-  const fetched = groupsQuery.data !== undefined;
+  const fetched = groupsQuery.data !== undefined || groupsQuery.isError;
+  const fetchError = groupsQuery.error ? (groupsQuery.error as Error).message : '';
 
   const invalidate = useCallback(() => {
     queryClient.invalidateQueries({ queryKey: keys.clients.root() });
@@ -435,6 +437,13 @@ export default function GroupsPage() {
             <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" loading={loading} onClick={() => groupsQuery.refetch()}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
                   <Col span={24}>

+ 10 - 0
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -1,11 +1,13 @@
 import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
+  Button,
   Card,
   Col,
   ConfigProvider,
   Layout,
   Modal,
+  Result,
   Row,
   Spin,
   Statistic,
@@ -74,6 +76,7 @@ export default function InboundsPage() {
 
   const {
     fetched,
+    fetchError,
     dbInbounds,
     clientCount,
     onlineClients,
@@ -559,6 +562,13 @@ export default function InboundsPage() {
             <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, 12]}>
                   <Col span={24}>

+ 4 - 1
frontend/src/pages/inbounds/useInbounds.ts

@@ -248,7 +248,9 @@ export function useInbounds() {
     if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
   }, [lastOnlineQuery.data]);
 
-  const fetched = slimQuery.data !== undefined && defaultsQuery.data !== undefined;
+  const fetched = (slimQuery.data !== undefined || slimQuery.isError) && (defaultsQuery.data !== undefined || defaultsQuery.isError);
+  const fetchErrorSource = slimQuery.error || defaultsQuery.error;
+  const fetchError = fetchErrorSource ? (fetchErrorSource as Error).message : '';
 
   const refresh = useCallback(async () => {
     // Invalidate at the inbounds root so both `slim` (this page's list)
@@ -373,6 +375,7 @@ export function useInbounds() {
 
   return {
     fetched,
+    fetchError,
     dbInbounds,
     clientCount,
     onlineClients,

+ 9 - 1
frontend/src/pages/index/IndexPage.tsx

@@ -8,6 +8,7 @@ import {
   Layout,
   message,
   Modal,
+  Result,
   Row,
   Space,
   Spin,
@@ -58,7 +59,7 @@ import './IndexPage.css';
 export default function IndexPage() {
   const { t } = useTranslation();
   const { isDark, isUltra, antdThemeConfig } = useTheme();
-  const { status, fetched, refresh } = useStatusQuery();
+  const { status, fetched, fetchError, refresh } = useStatusQuery();
   const { isMobile } = useMediaQuery();
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
@@ -168,6 +169,13 @@ export default function IndexPage() {
             >
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" onClick={refresh}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, 12]}>
                   <Col span={24}>

+ 9 - 2
frontend/src/pages/nodes/NodesPage.tsx

@@ -1,7 +1,7 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, Statistic, message } from 'antd';
+import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
 import {
   CheckCircleOutlined,
   CloseCircleOutlined,
@@ -29,7 +29,7 @@ export default function NodesPage() {
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
-  const { nodes, loading, fetched, totals } = useNodesQuery();
+  const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
   const { create, update, remove, setEnable, testConnection, probe, updatePanels } = useNodeMutations();
 
   const { data: latestVersion = '' } = useQuery({
@@ -159,6 +159,13 @@ export default function NodesPage() {
             <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
               {!fetched ? (
                 <div className="loading-spacer" />
+              ) : fetchError ? (
+                <Result
+                  status="error"
+                  title={t('somethingWentWrong')}
+                  subTitle={fetchError}
+                  extra={<Button type="primary" loading={loading} onClick={() => refetch()}>{t('refresh')}</Button>}
+                />
               ) : (
                 <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
                   <Col span={24}>