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:
MHSanaei 2026-06-01 07:43:32 +02:00
parent dd14e9b3b0
commit b9cbc0c1e8
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
9 changed files with 60 additions and 9 deletions

View file

@ -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,
}; };
} }

View file

@ -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,
}; };
} }

View file

@ -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,

View file

@ -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}>

View file

@ -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}>

View file

@ -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}>

View file

@ -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,

View file

@ -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}>

View file

@ -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}>