This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 79962a4ff fix(web): tell a failed instance-list load apart from an 
empty instance (#4953)
79962a4ff is described below

commit 79962a4ffa105713fa99d756de0516b9b62b0563
Author: 烤化の初雪 <[email protected]>
AuthorDate: Sat Sep 26 18:47:21 2026 +0800

    fix(web): tell a failed instance-list load apart from an empty instance 
(#4953)
    
    fix(web): tell a failed instance-list load from an empty instance
    
    useInstanceFilter swallowed the /instances failure and reported no selected
    instance, which is the one state the instance pages cannot work with: every
    instance-scoped endpoint requires an instanceId, so 
topic/consumer/message/acl/
    dlq cleared their rows and their totals and showed '0 of them' with no 
error and
    no retry. A user cannot tell that from an instance that really has nothing.
    
    The hook now exposes the failure and a retry, and the shared instance 
selector
    surfaces both, so the message appears next to the control the user already 
used
    to choose an instance.
---
 web/src/components/InstanceSelect.tsx             | 26 +++++++++++-
 web/src/hooks/useInstanceFilter.test.tsx          | 49 +++++++++++++++++++++--
 web/src/hooks/useInstanceFilter.ts                | 19 +++++++--
 web/src/pages/instance/__tests__/DLQPage.test.tsx | 13 ++++++
 web/src/pages/instance/acl.tsx                    | 11 ++++-
 web/src/pages/instance/consumer.tsx               |  4 ++
 web/src/pages/instance/dlq.tsx                    |  5 ++-
 web/src/pages/instance/message.tsx                | 11 ++++-
 web/src/pages/instance/topic.tsx                  |  4 ++
 9 files changed, 130 insertions(+), 12 deletions(-)

diff --git a/web/src/components/InstanceSelect.tsx 
b/web/src/components/InstanceSelect.tsx
index 799c1f950..7fd775b38 100644
--- a/web/src/components/InstanceSelect.tsx
+++ b/web/src/components/InstanceSelect.tsx
@@ -15,8 +15,9 @@
  * limitations under the License.
  */
 
-import { Select } from 'antd';
+import { Button, Select, theme } from 'antd';
 import type { CSSProperties } from 'react';
+import { useLang } from '../i18n/LangContext';
 
 export interface InstanceOption {
   value: string;
@@ -29,6 +30,9 @@ interface InstanceSelectProps {
   options: InstanceOption[];
   style?: CSSProperties;
   placeholder?: string;
+  /** The instance list request failed, so `options` is empty for a reason the 
user has to be told. */
+  failed?: boolean;
+  onRetry?: () => void;
 }
 
 /**
@@ -41,8 +45,12 @@ export function InstanceSelect({
   options,
   style,
   placeholder = '选择实例',
+  failed,
+  onRetry,
 }: InstanceSelectProps) {
-  return (
+  const { t } = useLang();
+  const { token } = theme.useToken();
+  const select = (
     <Select
       showSearch
       allowClear
@@ -69,6 +77,20 @@ export function InstanceSelect({
       style={style ?? { width: 220 }}
     />
   );
+  if (!failed) {
+    return select;
+  }
+  return (
+    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
+      {select}
+      <span style={{ fontSize: 14, color: token.colorError 
}}>{t('instance.listLoadFailed')}</span>
+      {onRetry && (
+        <Button type="link" size="small" style={{ padding: 0, height: 'auto' 
}} onClick={onRetry}>
+          {t('common.retry')}
+        </Button>
+      )}
+    </span>
+  );
 }
 
 export default InstanceSelect;
diff --git a/web/src/hooks/useInstanceFilter.test.tsx 
b/web/src/hooks/useInstanceFilter.test.tsx
index b73abc2dd..fa9d6438d 100644
--- a/web/src/hooks/useInstanceFilter.test.tsx
+++ b/web/src/hooks/useInstanceFilter.test.tsx
@@ -16,7 +16,7 @@
  */
 
 import { describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
 import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
 import { useInstanceFilter } from './useInstanceFilter';
 
@@ -28,8 +28,14 @@ vi.mock('../services/instanceService', () => 
instanceServiceMocks);
 
 function InstanceRouteProbe() {
   const { pathname } = useLocation();
-  const { selectedInstanceId } = useInstanceFilter();
-  return <output>{`${pathname}|${selectedInstanceId}`}</output>;
+  const { selectedInstanceId, instancesFailed, reloadInstances } = 
useInstanceFilter();
+  return (
+    <>
+      <output>{`${pathname}|${selectedInstanceId}`}</output>
+      <output data-testid="instances-failed">{`${instancesFailed}`}</output>
+      <button onClick={reloadInstances}>retry</button>
+    </>
+  );
 }
 
 describe('useInstanceFilter', () => {
@@ -61,6 +67,43 @@ describe('useInstanceFilter', () => {
     });
   });
 
+  it('reports a failed instance list and recovers from a retry', async () => {
+    instanceServiceMocks.listInstances
+      .mockRejectedValueOnce(new Error('offline'))
+      .mockResolvedValueOnce([
+        {
+          id: 7,
+          name: 'instance-a',
+          remark: '',
+          type: 'DIRECT',
+          endpoint: '127.0.0.1:9876',
+          topicCount: 0,
+          consumerGroupCount: 0,
+          gmtCreate: '2026-01-01T00:00:00Z',
+          gmtModified: '2026-01-01T00:00:00Z',
+        },
+      ]);
+
+    render(
+      <MemoryRouter initialEntries={['/instance/instance-a/topic']}>
+        <Routes>
+          <Route path="/instance/:instanceId/topic" 
element={<InstanceRouteProbe />} />
+        </Routes>
+      </MemoryRouter>,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByTestId('instances-failed')).toHaveTextContent('true');
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: 'retry' }));
+
+    await waitFor(() => {
+      
expect(screen.getByText('/instance/instance-a/topic|instance-a')).toBeInTheDocument();
+      
expect(screen.getByTestId('instances-failed')).toHaveTextContent('false');
+    });
+  });
+
   it('recovers from a malformed encoded instance id', async () => {
     instanceServiceMocks.listInstances.mockResolvedValue([
       {
diff --git a/web/src/hooks/useInstanceFilter.ts 
b/web/src/hooks/useInstanceFilter.ts
index 2d9e63a84..f6666f96a 100644
--- a/web/src/hooks/useInstanceFilter.ts
+++ b/web/src/hooks/useInstanceFilter.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import { useLocation, useNavigate } from 'react-router-dom';
 import { listInstances } from '../services/instanceService';
 import type { Instance } from '../api/instance';
@@ -34,7 +34,11 @@ function decodeRouteSegment(segment: string | undefined) {
 
 /**
  * 实例维度页面的公共筛选逻辑:从 /instance/:instanceId/<section> 路由解析当前实例,
- * 无实例参数时重定向到第一个实例;实例列表加载失败时降级为不过滤。
+ * 无实例参数时重定向到第一个实例。
+ *
+ * 实例列表加载失败时没有可用的降级:服务端的实例维度接口都要求 instanceId,所以页面只能
+ * 停在“无实例”状态。失败因此必须暴露给页面(instancesFailed),否则一次失败的请求与
+ * “该实例下确实没有数据”在界面上无法区分,而页面会把后者当成空结果展示。
  */
 export function useInstanceFilter() {
   const navigate = useNavigate();
@@ -47,6 +51,8 @@ export function useInstanceFilter() {
 
   const [instances, setInstances] = useState<Instance[]>([]);
   const [instancesLoading, setInstancesLoading] = useState(true);
+  const [instancesFailed, setInstancesFailed] = useState(false);
+  const [reloadToken, setReloadToken] = useState(0);
 
   // Keep the latest selected instance id in a ref so the instance *list* is 
only
   // fetched when needed (section / navigation changes) and not re-fetched 
every
@@ -62,6 +68,7 @@ export function useInstanceFilter() {
     void listInstances()
       .then((nextInstances) => {
         if (cancelled) return;
+        setInstancesFailed(false);
         setInstances(nextInstances);
         const selectedInstanceId = routeInstanceIdRef.current;
         const isKnownInstance = nextInstances.some(
@@ -74,7 +81,7 @@ export function useInstanceFilter() {
         }
       })
       .catch(() => {
-        // 实例列表加载失败时不做实例过滤,保持页面数据可用
+        if (!cancelled) setInstancesFailed(true);
       })
       .finally(() => {
         if (!cancelled) setInstancesLoading(false);
@@ -82,7 +89,7 @@ export function useInstanceFilter() {
     return () => {
       cancelled = true;
     };
-  }, [navigate, section]);
+  }, [navigate, section, reloadToken]);
 
   const selectedInstanceId =
     routeInstanceId !== undefined && instances.some((instance) => 
instance.name === routeInstanceId)
@@ -94,6 +101,8 @@ export function useInstanceFilter() {
     navigate(`/instance/${encodeURIComponent(name)}/${section}`);
   };
 
+  const reloadInstances = useCallback(() => setReloadToken((token) => token + 
1), []);
+
   const instanceOptions = instances.map((instance) => ({
     value: instance.name,
     label: instance.name,
@@ -102,9 +111,11 @@ export function useInstanceFilter() {
   return {
     instances,
     instancesLoading,
+    instancesFailed,
     selectedInstanceId,
     selectedInstance,
     selectInstance,
+    reloadInstances,
     instanceOptions,
   };
 }
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx 
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index ebb44bf60..d29cfeb85 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -24,6 +24,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, 
it, vi } from 'vite
 import type { DLQGroup, DLQGroupPage, DLQMessagePage, DLQResendResult } from 
'../../../api/message';
 import { LangProvider } from '../../../i18n/LangContext';
 import * as messageService from '../../../services/messageService';
+import * as instanceService from '../../../services/instanceService';
 import DLQPage, { formatDateTime } from '../dlq';
 
 vi.mock('../../../services/messageService', () => ({
@@ -155,6 +156,18 @@ describe('DLQ page', () => {
     vi.clearAllMocks();
   });
 
+  it('says so when the instance list itself failed to load', async () => {
+    // Every instance-scoped request needs an instanceId, so a failed 
/instances request leaves the page
+    // with nothing it can show. Rendering that as an empty result tells the 
operator the instance has no
+    // dead-letter messages, which is the opposite of what is actually known.
+    vi.mocked(instanceService.listInstances).mockRejectedValueOnce(new 
Error('offline'));
+
+    renderWithProviders(<DLQPage />);
+
+    expect(await screen.findByText(/实例列表加载失败/)).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument();
+  });
+
   it('renders invalid message timestamps as unavailable without throwing', () 
=> {
     expect(formatDateTime(Number.NaN)).toBe('-');
     expect(formatDateTime(Number.POSITIVE_INFINITY)).toBe('-');
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index 282c598fc..d09dbff54 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -115,13 +115,20 @@ const isFormValidationError = (error: unknown) =>
    ═══════════════════════════════════════════ */
 type AclPageContentProps = Pick<
   ReturnType<typeof useInstanceFilter>,
-  'selectedInstanceId' | 'selectInstance' | 'instanceOptions' | 'instances'
+  | 'selectedInstanceId'
+  | 'selectInstance'
+  | 'instanceOptions'
+  | 'instances'
+  | 'instancesFailed'
+  | 'reloadInstances'
 >;
 
 const AclPageContent = ({
   selectedInstanceId,
   selectInstance,
   instanceOptions,
+  instancesFailed,
+  reloadInstances,
   instances,
 }: AclPageContentProps) => {
   const { t } = useLang();
@@ -1122,6 +1129,8 @@ const AclPageContent = ({
                       onChange={selectInstance}
                       options={instanceOptions}
                       style={{ width: 220 }}
+                      failed={instancesFailed}
+                      onRetry={reloadInstances}
                     />
                     <Input
                       placeholder={t('acl.searchPrincipal')}
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index 7a57c1e10..5559fd486 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -230,6 +230,8 @@ const ConsumerPageContent = ({
   selectInstance,
   instanceOptions,
   instancesLoading,
+  instancesFailed,
+  reloadInstances,
 }: ConsumerPageContentProps) => {
   const { t, lang } = useLang();
   const isCloudInstance =
@@ -1440,6 +1442,8 @@ const ConsumerPageContent = ({
             onChange={selectInstance}
             options={instanceOptions}
             style={{ width: 220 }}
+            failed={instancesFailed}
+            onRetry={reloadInstances}
           />
           <Input.Search
             placeholder="搜索 Group 名称或 Topic"
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 165159427..9062380b8 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -85,7 +85,8 @@ const exportDLQGroups = (groups: DLQGroup[], filename: 
string) => {
    ═══════════════════════════════════════════ */
 const DLQPage = () => {
   const { t } = useLang();
-  const { selectedInstanceId, selectInstance, instanceOptions } = 
useInstanceFilter();
+  const { selectedInstanceId, selectInstance, instanceOptions, 
instancesFailed, reloadInstances } =
+    useInstanceFilter();
   const [groups, setGroups] = useState<DLQGroup[]>([]);
   const [total, setTotal] = useState(0);
   const [page, setPage] = useState(1);
@@ -682,6 +683,8 @@ const DLQPage = () => {
             onChange={handleInstanceChange}
             options={instanceOptions}
             style={{ width: 220 }}
+            failed={instancesFailed}
+            onRetry={reloadInstances}
           />
           <Input.Search
             placeholder="搜索 Group 名称或 DLQ Topic"
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index 197d403da..664953e15 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -314,10 +314,13 @@ type InstanceFilterProps = {
   selectedInstanceId: string | undefined;
   selectInstance: (instanceId: string) => void;
   instanceOptions: { value: string; label: string }[];
+  instancesFailed: boolean;
+  reloadInstances: () => void;
 };
 
 const MessagePage = () => {
-  const { selectedInstanceId, selectInstance, instanceOptions } = 
useInstanceFilter();
+  const { selectedInstanceId, selectInstance, instanceOptions, 
instancesFailed, reloadInstances } =
+    useInstanceFilter();
   // Keying the content by the selected instance makes React remount it 
whenever the instance
   // changes — whether from this page's own <Select> or from the shared 
filter/route elsewhere —
   // so query results, the detail modal and in-flight request ownership all 
reset cleanly.
@@ -327,6 +330,8 @@ const MessagePage = () => {
       selectedInstanceId={selectedInstanceId}
       selectInstance={selectInstance}
       instanceOptions={instanceOptions}
+      instancesFailed={instancesFailed}
+      reloadInstances={reloadInstances}
     />
   );
 };
@@ -338,6 +343,8 @@ const MessagePageContent = ({
   selectedInstanceId,
   selectInstance,
   instanceOptions,
+  instancesFailed,
+  reloadInstances,
 }: InstanceFilterProps) => {
   const { t } = useLang();
   const [topicOptions, setTopicOptions] = useState<string[]>([]);
@@ -1115,6 +1122,8 @@ const MessagePageContent = ({
               onChange={selectInstance}
               options={instanceOptions}
               style={{ width: 220 }}
+              failed={instancesFailed}
+              onRetry={reloadInstances}
             />
             <Segmented
               options={QUERY_OPTIONS.map(({ value }) => ({
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 0cbaf8580..ea6006e5e 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -344,6 +344,8 @@ const TopicPageContent = ({
   selectInstance,
   instanceOptions,
   instancesLoading,
+  instancesFailed,
+  reloadInstances,
   instances,
 }: TopicPageContentProps) => {
   const { t } = useLang();
@@ -1535,6 +1537,8 @@ const TopicPageContent = ({
             }}
             options={instanceOptions}
             style={{ width: 220 }}
+            failed={instancesFailed}
+            onRetry={reloadInstances}
           />
           <Input.Search
             placeholder="搜索 Topic 名称"

Reply via email to