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 7f0b0607 fix: surface unavailable runtime providers (#946)
7f0b0607 is described below
commit 7f0b0607c496ca3a7b6956d21c293c321a4fe3cb
Author: aias00 <[email protected]>
AuthorDate: Tue Aug 4 03:06:38 2026 -0700
fix: surface unavailable runtime providers (#946)
* fix: surface unavailable message provider errors
* fix: surface unavailable dlq provider errors
* fix: surface unavailable client provider errors
---
.../pages/cluster/__tests__/ClientsPage.test.tsx | 12 ++++++
web/src/pages/cluster/clients.tsx | 40 ++++++++++++++++--
web/src/pages/instance/__tests__/DLQPage.test.tsx | 26 ++++++++++++
.../__tests__/MessagePageAsyncState.test.tsx | 30 ++++++++++++++
web/src/pages/instance/dlq.tsx | 47 ++++++++++++++++++++--
web/src/pages/instance/message.tsx | 46 +++++++++++++++++++--
6 files changed, 189 insertions(+), 12 deletions(-)
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index beb6b5e6..d72367a5 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -187,6 +187,18 @@ describe('Clients page', () => {
).toBeInTheDocument();
});
+ it('surfaces unavailable provider errors from the client API', async () => {
+ vi.mocked(connectionsService.listConnections).mockRejectedValue(
+ new Error('Client connection provider is not configured'),
+ );
+ renderWithProviders(<ClientsPage />);
+
+ expect(
+ await screen.findByText('Client connection provider is not configured'),
+ ).toBeInTheDocument();
+
expect(within(screen.getByTestId('connection-total')).getByText('0')).toBeInTheDocument();
+ });
+
it('opens a client detail dialog from the connection table', async () => {
const user = userEvent.setup();
renderWithProviders(<ClientsPage />);
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index baa8d789..5c7581e3 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -17,6 +17,7 @@
import { useEffect, useMemo, useState } from 'react';
import {
+ Alert,
Button,
Card,
Descriptions,
@@ -29,7 +30,6 @@ import {
Table,
Tag,
Typography,
- message,
theme,
} from 'antd';
import { Eye, MagnifyingGlass } from '@phosphor-icons/react';
@@ -42,6 +42,7 @@ import { listConnections } from
'../../services/connectionsService';
import { formatDateTime } from '../../utils/format';
const { Text } = Typography;
+const DEFAULT_LOAD_ERROR = '客户端连接加载失败,请稍后重试';
/* ─── Helpers ─── */
@@ -76,6 +77,27 @@ const countBy = (values: string[]) =>
.map(([label, count]) => ({ label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
+type ApiErrorLike = {
+ message?: unknown;
+ response?: {
+ data?: {
+ message?: unknown;
+ };
+ };
+};
+
+function getLoadErrorMessage(error: unknown): string {
+ const apiError = error as ApiErrorLike;
+ const responseMessage = apiError.response?.data?.message;
+ if (typeof responseMessage === 'string' && responseMessage.trim()) {
+ return responseMessage;
+ }
+ if (typeof apiError.message === 'string' && apiError.message.trim()) {
+ return apiError.message;
+ }
+ return DEFAULT_LOAD_ERROR;
+}
+
/* ═══════════════════════════════════════════
ClientsPage
═══════════════════════════════════════════ */
@@ -87,16 +109,22 @@ const ClientsPage = () => {
const [search, setSearch] = useState('');
const [clusterFilter, setClusterFilter] = useState<string>('ALL');
const [selectedConnection, setSelectedConnection] =
useState<ClientConnection | null>(null);
+ const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void listConnections()
.then((nextConnections) => {
- if (!cancelled) setConnections(nextConnections);
+ if (!cancelled) {
+ setConnections(nextConnections);
+ setLoadError(null);
+ }
})
- .catch(() => {
- if (!cancelled) message.error('客户端连接加载失败,请稍后重试');
+ .catch((error) => {
+ if (!cancelled) {
+ setLoadError(getLoadErrorMessage(error));
+ }
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -298,6 +326,10 @@ const ClientsPage = () => {
subtitle={`${t('clients.title')} — ${filtered.length} connections`}
/>
+ {loadError && (
+ <Alert showIcon type="warning" message={loadError} style={{
marginBottom: 16 }} />
+ )}
+
{/* ─── Filter Bar ─── */}
<Flex justify="space-between" align="center" style={{ marginBottom: 16
}}>
<Space size={12} wrap>
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index bd5d2b82..4c1b8adb 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -110,6 +110,15 @@ describe('DLQ page', () => {
expect(messageService.listDLQGroups).toHaveBeenCalledTimes(1);
});
+ it('surfaces unavailable DLQ provider errors when loading groups', async ()
=> {
+ vi.mocked(messageService.listDLQGroups).mockRejectedValue(
+ new Error('DLQ provider is not configured'),
+ );
+ renderWithProviders(<DLQPage />);
+
+ expect(await screen.findByText('DLQ provider is not
configured')).toBeInTheDocument();
+ });
+
it('opens a detail dialog with the selected group metadata', async () => {
const user = userEvent.setup();
renderWithProviders(<DLQPage />);
@@ -181,4 +190,21 @@ describe('DLQ page', () => {
await waitFor(() =>
expect(within(orderRow).getByRole('checkbox')).toBeDisabled());
expect(screen.getByRole('button', { name: /批量导出/ })).toBeDisabled();
});
+
+ it('surfaces unavailable DLQ provider errors when retry submission fails',
async () => {
+ vi.mocked(messageService.resendDLQ).mockRejectedValue(
+ new Error('DLQ provider is not configured'),
+ );
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ const orderRow = (await screen.findByText('cg-order')).closest('tr');
+ if (!orderRow) throw new Error('DLQ group row not found');
+
+ await user.click(within(orderRow).getByRole('button', { name: '重投消息' }));
+ await user.type(screen.getByPlaceholderText('输入目标 Topic 名称'),
'orders-retry');
+ await user.click(screen.getByRole('button', { name: '确认重投' }));
+
+ expect(await screen.findByText('DLQ provider is not
configured')).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index a2ebe31a..fc7c0fc3 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -130,6 +130,36 @@ describe('MessagePage async request ownership', () => {
expect(screen.queryByText('late-after-reset')).not.toBeInTheDocument();
});
+ it('surfaces unavailable message provider errors from query requests', async
() => {
+ serviceMocks.queryMessages.mockRejectedValue(
+ new Error('Message query provider is not configured'),
+ );
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+
+ expect(await screen.findByText('Message query provider is not
configured')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /最近查询/ })).toBeDisabled();
+ });
+
+ it('surfaces unavailable message provider errors from trace requests', async
() => {
+ serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
+ serviceMocks.getMessageTrace.mockRejectedValue(
+ new Error('Message query provider is not configured'),
+ );
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+ const row = await screen.findByRole('row', { name: /message-a/ });
+ await user.click(within(row).getByRole('button', { name: /轨迹/ }));
+
+ const dialog = await screen.findByRole('dialog', { name: '消息详情' });
+ expect(
+ await within(dialog).findByText('Message query provider is not
configured'),
+ ).toBeInTheDocument();
+ });
it('keeps normal message resend disabled until a real API is wired', async
() => {
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 3ff3e191..8f3cdf33 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -17,6 +17,7 @@
import { useEffect, useMemo, useState } from 'react';
import {
+ Alert,
Card,
Table,
Button,
@@ -42,9 +43,32 @@ import { downloadBlob } from '../../utils/download';
const { Text } = Typography;
const { RangePicker } = DatePicker;
+const DEFAULT_LOAD_ERROR = '死信队列加载失败,请稍后重试';
+const DEFAULT_RETRY_ERROR = '提交重投任务失败,请稍后重试';
/* ─── Helpers ─── */
+type ApiErrorLike = {
+ message?: unknown;
+ response?: {
+ data?: {
+ message?: unknown;
+ };
+ };
+};
+
+const getErrorMessage = (error: unknown, fallback: string): string => {
+ const apiError = error as ApiErrorLike;
+ const responseMessage = apiError.response?.data?.message;
+ if (typeof responseMessage === 'string' && responseMessage.trim()) {
+ return responseMessage;
+ }
+ if (typeof apiError.message === 'string' && apiError.message.trim()) {
+ return apiError.message;
+ }
+ return fallback;
+};
+
const formatDateTime = (iso: string): string => {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
@@ -93,6 +117,8 @@ const DLQPage = () => {
const [retrySubmitting, setRetrySubmitting] = useState(false);
const [detailGroup, setDetailGroup] = useState<DLQGroup | null>(null);
const [selectedGroupNames, setSelectedGroupNames] = useState<string[]>([]);
+ const [loadError, setLoadError] = useState<string | null>(null);
+ const [retryError, setRetryError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@@ -101,6 +127,7 @@ const DLQPage = () => {
.then((nextGroups) => {
if (!cancelled) {
setGroups(nextGroups);
+ setLoadError(null);
const availableGroups = new Set(
nextGroups.filter((group) => group.messageCount > 0).map((group)
=> group.groupName),
);
@@ -109,8 +136,8 @@ const DLQPage = () => {
);
}
})
- .catch(() => {
- if (!cancelled) message.error('死信队列加载失败,请稍后重试');
+ .catch((error) => {
+ if (!cancelled) setLoadError(getErrorMessage(error,
DEFAULT_LOAD_ERROR));
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -140,6 +167,7 @@ const DLQPage = () => {
setRetryGroup(group);
setRetryRange([dayjs().subtract(1, 'day'), dayjs()]);
setRetryTargetTopic('');
+ setRetryError(null);
setRetryModalOpen(true);
};
@@ -151,6 +179,7 @@ const DLQPage = () => {
if (!retryGroup) return;
setRetrySubmitting(true);
+ setRetryError(null);
try {
await resendDLQ({
groupName: retryGroup.groupName,
@@ -162,8 +191,9 @@ const DLQPage = () => {
message.success(`已提交重投任务:${retryGroup.groupName} → ${retryTargetTopic}`);
setRetryModalOpen(false);
setRetryGroup(null);
- } catch {
- message.error('提交重投任务失败,请稍后重试');
+ setRetryError(null);
+ } catch (error) {
+ setRetryError(getErrorMessage(error, DEFAULT_RETRY_ERROR));
} finally {
setRetrySubmitting(false);
}
@@ -308,6 +338,10 @@ const DLQPage = () => {
</Button>
</Flex>
+ {loadError && (
+ <Alert showIcon type="warning" message={loadError} style={{
marginBottom: 16 }} />
+ )}
+
{/* ── Table ── */}
<Card bodyStyle={{ padding: 0 }}>
<Table
@@ -344,6 +378,7 @@ const DLQPage = () => {
onCancel={() => {
setRetryModalOpen(false);
setRetryGroup(null);
+ setRetryError(null);
}}
onOk={handleRetry}
confirmLoading={retrySubmitting}
@@ -354,6 +389,10 @@ const DLQPage = () => {
>
{retryGroup && (
<div style={{ marginTop: 16 }}>
+ {retryError && (
+ <Alert showIcon type="warning" message={retryError} style={{
marginBottom: 16 }} />
+ )}
+
<div
style={{
marginBottom: 16,
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index ee56b2b6..5162392f 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -17,6 +17,7 @@
import { useEffect, useRef, useState } from 'react';
import {
+ Alert,
Card,
Table,
Tag,
@@ -60,6 +61,8 @@ import { downloadBlob } from '../../utils/download';
const { Paragraph, Text } = Typography;
const { RangePicker } = DatePicker;
+const DEFAULT_QUERY_ERROR = '消息查询失败,请稍后重试';
+const DEFAULT_TRACE_ERROR = '消息轨迹加载失败,请稍后重试';
/* ─── Constants ─── */
@@ -70,6 +73,15 @@ type RecentQuery = {
params: MessageQuery;
};
+type ApiErrorLike = {
+ message?: unknown;
+ response?: {
+ data?: {
+ message?: unknown;
+ };
+ };
+};
+
const QUERY_HISTORY_STORAGE_KEY = 'rocketmq-studio-message-query-history';
const MAX_QUERY_HISTORY = 5;
const RESEND_UNAVAILABLE_MESSAGE = '当前版本尚未接入普通消息重新发送接口';
@@ -179,6 +191,18 @@ const queryLabel = ({ mode, params }: RecentQuery): string
=> {
return `Topic: ${params.topic || '全部'}`;
};
+const getErrorMessage = (error: unknown, fallback: string): string => {
+ const apiError = error as ApiErrorLike;
+ const responseMessage = apiError.response?.data?.message;
+ if (typeof responseMessage === 'string' && responseMessage.trim()) {
+ return responseMessage;
+ }
+ if (typeof apiError.message === 'string' && apiError.message.trim()) {
+ return apiError.message;
+ }
+ return fallback;
+};
+
/* ═══════════════════════════════════════════
MessagePage
═══════════════════════════════════════════ */
@@ -218,6 +242,8 @@ const MessagePage = () => {
const [selectedMsg, setSelectedMsg] = useState<MessageRecord | null>(null);
const [traceData, setTraceData] = useState<TraceRecord | null>(null);
const [traceLoading, setTraceLoading] = useState(false);
+ const [queryError, setQueryError] = useState<string | null>(null);
+ const [traceError, setTraceError] = useState<string | null>(null);
const [recentQueries, setRecentQueries] =
useState<RecentQuery[]>(loadRecentQueries);
const queryGenerationRef = useRef(0);
const traceGenerationRef = useRef(0);
@@ -238,6 +264,7 @@ const MessagePage = () => {
setMsgIdInput('');
setDateRange(getDefaultRange());
setMessages([]);
+ setQueryError(null);
setQueryLoading(false);
};
@@ -262,15 +289,17 @@ const MessagePage = () => {
const requestGeneration = queryGenerationRef.current + 1;
queryGenerationRef.current = requestGeneration;
setQueryLoading(true);
+ setQueryError(null);
try {
const result = await queryMessages(params);
if (queryGenerationRef.current !== requestGeneration) return;
setMessages(result);
+ setQueryError(null);
saveRecentQuery(mode, params);
message.success(`查询完成,共 ${result.length} 条`);
- } catch {
+ } catch (error) {
if (queryGenerationRef.current === requestGeneration) {
- message.error('消息查询失败,请稍后重试');
+ setQueryError(getErrorMessage(error, DEFAULT_QUERY_ERROR));
}
} finally {
if (queryGenerationRef.current === requestGeneration) {
@@ -360,13 +389,15 @@ const MessagePage = () => {
setModalOpen(true);
setTraceData(null);
setTraceLoading(true);
+ setTraceError(null);
try {
const result = await getMessageTrace(record.msgId);
if (traceGenerationRef.current !== requestGeneration) return;
setTraceData(result);
- } catch {
+ setTraceError(null);
+ } catch (error) {
if (traceGenerationRef.current === requestGeneration) {
- message.error('消息轨迹加载失败,请稍后重试');
+ setTraceError(getErrorMessage(error, DEFAULT_TRACE_ERROR));
}
} finally {
if (traceGenerationRef.current === requestGeneration) {
@@ -379,6 +410,7 @@ const MessagePage = () => {
traceGenerationRef.current += 1;
setModalOpen(false);
setTraceLoading(false);
+ setTraceError(null);
};
const handleDownload = (record: MessageRecord) => {
@@ -600,6 +632,8 @@ const MessagePage = () => {
label: '消息轨迹',
children: traceLoading ? (
<Typography.Text type="secondary">正在加载轨迹数据…</Typography.Text>
+ ) : traceError ? (
+ <Alert showIcon type="warning" message={traceError} />
) : traceData?.nodes?.length ? (
<Steps
direction="vertical"
@@ -748,6 +782,10 @@ const MessagePage = () => {
</Space>
</Card>
+ {queryError && (
+ <Alert showIcon type="warning" message={queryError} style={{
marginBottom: 16 }} />
+ )}
+
{/* ── Results Table ── */}
<Card bodyStyle={{ padding: 0 }}>
<Table