mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
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
This commit is contained in:
parent
dd14e9b3b0
commit
b9cbc0c1e8
9 changed files with 60 additions and 9 deletions
|
|
@ -76,6 +76,8 @@ export function useNodesQuery() {
|
||||||
nodes,
|
nodes,
|
||||||
totals,
|
totals,
|
||||||
loading: query.isFetching,
|
loading: query.isFetching,
|
||||||
fetched: query.data !== undefined,
|
fetched: query.data !== undefined || query.isError,
|
||||||
|
fetchError: query.error ? (query.error as Error).message : '',
|
||||||
|
refetch: query.refetch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ export function useStatusQuery() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
fetched: query.data !== undefined,
|
fetched: query.data !== undefined || query.isError,
|
||||||
|
fetchError: query.error ? (query.error as Error).message : '',
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,8 @@ export function useClients() {
|
||||||
const total = listQuery.data?.total ?? 0;
|
const total = listQuery.data?.total ?? 0;
|
||||||
const filtered = listQuery.data?.filtered ?? 0;
|
const filtered = listQuery.data?.filtered ?? 0;
|
||||||
const allGroups = listQuery.data?.groups ?? [];
|
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 loading = listQuery.isFetching;
|
||||||
|
|
||||||
const inbounds = inboundOptionsQuery.data ?? [];
|
const inbounds = inboundOptionsQuery.data ?? [];
|
||||||
|
|
@ -532,6 +533,7 @@ export function useClients() {
|
||||||
onlines,
|
onlines,
|
||||||
loading,
|
loading,
|
||||||
fetched,
|
fetched,
|
||||||
|
fetchError,
|
||||||
subSettings,
|
subSettings,
|
||||||
ipLimitEnable,
|
ipLimitEnable,
|
||||||
tgBotEnable,
|
tgBotEnable,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Modal,
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
Popover,
|
Popover,
|
||||||
|
Result,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
|
|
@ -191,11 +192,12 @@ export default function ClientsPage() {
|
||||||
summary: serverSummary,
|
summary: serverSummary,
|
||||||
allGroups,
|
allGroups,
|
||||||
setQuery,
|
setQuery,
|
||||||
inbounds, onlines, loading, fetched, subSettings,
|
inbounds, onlines, loading, fetched, fetchError, subSettings,
|
||||||
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
|
ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
|
||||||
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
|
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
|
||||||
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
resetTraffic, resetAllTraffics, delDepleted, setEnable,
|
||||||
applyTrafficEvent, applyClientStatsEvent,
|
applyTrafficEvent, applyClientStatsEvent,
|
||||||
|
refresh,
|
||||||
hydrate,
|
hydrate,
|
||||||
} = useClients();
|
} = useClients();
|
||||||
|
|
||||||
|
|
@ -795,6 +797,13 @@ export default function ClientsPage() {
|
||||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||||
{!fetched ? (
|
{!fetched ? (
|
||||||
<div className="loading-spacer" />
|
<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]}>
|
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Input,
|
Input,
|
||||||
Layout,
|
Layout,
|
||||||
Modal,
|
Modal,
|
||||||
|
Result,
|
||||||
Row,
|
Row,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
|
@ -97,7 +98,8 @@ export default function GroupsPage() {
|
||||||
});
|
});
|
||||||
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
||||||
const loading = groupsQuery.isFetching;
|
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(() => {
|
const invalidate = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.root() });
|
queryClient.invalidateQueries({ queryKey: keys.clients.root() });
|
||||||
|
|
@ -435,6 +437,13 @@ export default function GroupsPage() {
|
||||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||||
{!fetched ? (
|
{!fetched ? (
|
||||||
<div className="loading-spacer" />
|
<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]}>
|
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
ConfigProvider,
|
ConfigProvider,
|
||||||
Layout,
|
Layout,
|
||||||
Modal,
|
Modal,
|
||||||
|
Result,
|
||||||
Row,
|
Row,
|
||||||
Spin,
|
Spin,
|
||||||
Statistic,
|
Statistic,
|
||||||
|
|
@ -74,6 +76,7 @@ export default function InboundsPage() {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetched,
|
fetched,
|
||||||
|
fetchError,
|
||||||
dbInbounds,
|
dbInbounds,
|
||||||
clientCount,
|
clientCount,
|
||||||
onlineClients,
|
onlineClients,
|
||||||
|
|
@ -559,6 +562,13 @@ export default function InboundsPage() {
|
||||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||||
{!fetched ? (
|
{!fetched ? (
|
||||||
<div className="loading-spacer" />
|
<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]}>
|
<Row gutter={[isMobile ? 8 : 16, 12]}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,9 @@ export function useInbounds() {
|
||||||
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
|
if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
|
||||||
}, [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 () => {
|
const refresh = useCallback(async () => {
|
||||||
// Invalidate at the inbounds root so both `slim` (this page's list)
|
// Invalidate at the inbounds root so both `slim` (this page's list)
|
||||||
|
|
@ -373,6 +375,7 @@ export function useInbounds() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetched,
|
fetched,
|
||||||
|
fetchError,
|
||||||
dbInbounds,
|
dbInbounds,
|
||||||
clientCount,
|
clientCount,
|
||||||
onlineClients,
|
onlineClients,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Layout,
|
Layout,
|
||||||
message,
|
message,
|
||||||
Modal,
|
Modal,
|
||||||
|
Result,
|
||||||
Row,
|
Row,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
|
@ -58,7 +59,7 @@ import './IndexPage.css';
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { status, fetched, refresh } = useStatusQuery();
|
const { status, fetched, fetchError, refresh } = useStatusQuery();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
|
@ -168,6 +169,13 @@ export default function IndexPage() {
|
||||||
>
|
>
|
||||||
{!fetched ? (
|
{!fetched ? (
|
||||||
<div className="loading-spacer" />
|
<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]}>
|
<Row gutter={[isMobile ? 8 : 16, 12]}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
|
|
@ -29,7 +29,7 @@ export default function NodesPage() {
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
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 { create, update, remove, setEnable, testConnection, probe, updatePanels } = useNodeMutations();
|
||||||
|
|
||||||
const { data: latestVersion = '' } = useQuery({
|
const { data: latestVersion = '' } = useQuery({
|
||||||
|
|
@ -159,6 +159,13 @@ export default function NodesPage() {
|
||||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||||
{!fetched ? (
|
{!fetched ? (
|
||||||
<div className="loading-spacer" />
|
<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]}>
|
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue