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 名称"