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 84446e6de fix(web): route every page-level API error through the
shared extractor (#4837)
84446e6de is described below
commit 84446e6de147e61eb44ebaf9423f0c2ee2918cd5
Author: Wang1rrr <[email protected]>
AuthorDate: Thu Sep 24 10:48:37 2026 +0800
fix(web): route every page-level API error through the shared extractor
(#4837)
`message.tsx`, `dlq.tsx` and `clients.tsx` each redeclared an
`ApiErrorLike` type plus an identical `getErrorMessage(error, fallback)`, and
`certs.tsx` carried its own variant, so four copies of the same extraction
logic could drift from `utils/apiError.ts`. All four now call the shared
`describeApiError` / `describeThrownMessage`. This is a consolidation of
duplicated logic rather than a fix for an observable failure: the unguarded
`.response` read the copies shared is not reachable [...]
---
.../pages/cluster/__tests__/K8sCertsPage.test.tsx | 29 +++++++++
web/src/pages/cluster/certs.tsx | 10 ++--
web/src/pages/cluster/clients.tsx | 26 +-------
web/src/pages/instance/dlq.tsx | 34 +++--------
web/src/pages/instance/message.tsx | 30 ++--------
web/src/utils/apiError.test.ts | 69 ++++++++++++++++++++++
web/src/utils/apiError.ts | 4 ++
7 files changed, 122 insertions(+), 80 deletions(-)
diff --git a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
index 1573ac2dc..19e048b64 100644
--- a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
@@ -192,4 +192,33 @@ describe('K8sCertsPage', () => {
await waitFor(() => expect(deleteK8sCert).toHaveBeenCalledWith(1));
await waitFor(() =>
expect(screen.queryByText('rocketmq-prod-tls')).not.toBeInTheDocument());
});
+
+ it('surfaces the server rejection reason when the certificate list cannot be
loaded', async () => {
+ const failure: Error & { response?: { data?: { message?: unknown } } } =
new Error(
+ 'Request failed with status code 500',
+ );
+ failure.response = { data: { message: 'k8s cluster unreachable' } };
+ vi.mocked(listK8sCerts).mockRejectedValue(failure);
+
+ renderPage();
+
+ expect(await screen.findByText('k8s cluster
unreachable')).toBeInTheDocument();
+ expect(screen.queryByText('Request failed with status code
500')).not.toBeInTheDocument();
+ });
+
+ it('accepts a rejection reason carried by a bare object', async () => {
+ vi.mocked(listK8sCerts).mockRejectedValue({ message: 'certificate store
offline' });
+
+ renderPage();
+
+ expect(await screen.findByText('certificate store
offline')).toBeInTheDocument();
+ });
+
+ it('keeps the generic fallback when the rejection carries nothing usable',
async () => {
+ vi.mocked(listK8sCerts).mockRejectedValue({});
+
+ renderPage();
+
+ expect(await screen.findByText('请求失败,请稍后重试')).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index 75012e533..91ab2b611 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -37,13 +37,13 @@ import PageHeader from '../../components/PageHeader';
import InfoBanner from '../../components/InfoBanner';
import type { K8sCertInfo } from '../../api/cluster';
import { listK8sCerts, createK8sCert, deleteK8sCert } from
'../../services/clusterService';
+import { describeThrownMessage } from '../../utils/apiError';
import { formatDateTime } from '../../utils/format';
import { tableScrollX } from '../../utils/table';
const { Text } = Typography;
-const getErrorMessage = (error: unknown): string =>
- error instanceof Error && error.message ? error.message : '请求失败,请稍后重试';
+const DEFAULT_REQUEST_ERROR = '请求失败,请稍后重试';
interface CreateCertFormValues {
k8sId: string;
@@ -70,7 +70,7 @@ const K8sCertsPage = () => {
if (active) setCerts(data);
})
.catch((error: unknown) => {
- if (active) message.error(getErrorMessage(error));
+ if (active) message.error(describeThrownMessage(error) ||
DEFAULT_REQUEST_ERROR);
})
.finally(() => {
if (active) setLoading(false);
@@ -112,7 +112,7 @@ const K8sCertsPage = () => {
setCreateModalOpen(false);
createForm.resetFields();
} catch (error: unknown) {
- message.error(getErrorMessage(error));
+ message.error(describeThrownMessage(error) || DEFAULT_REQUEST_ERROR);
} finally {
setCreating(false);
}
@@ -125,7 +125,7 @@ const K8sCertsPage = () => {
setCerts((previous) => previous.filter((item) => item.id !== cert.id));
message.success(`证书「${cert.k8sId}」已删除`);
} catch (error: unknown) {
- message.error(getErrorMessage(error));
+ message.error(describeThrownMessage(error) || DEFAULT_REQUEST_ERROR);
} finally {
setDeletingId(null);
}
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 6fc768aa3..c6e51538d 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -45,6 +45,7 @@ import { listRegistryClusters } from
'../../services/clusterService';
import type { ClusterInfo } from '../../api/cluster';
import { formatDateTime } from '../../utils/format';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { describeThrownMessage } from '../../utils/apiError';
import { tableScrollX } from '../../utils/table';
import {
analyzeClientConnections,
@@ -142,27 +143,6 @@ 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;
-}
-
const displayMetadata = (value: string | null | undefined) => value || '-';
/**
@@ -241,7 +221,7 @@ const ClientsPage = () => {
setRegistryClusters([]);
setSelectedEndpoint(undefined);
setConnections([]);
- setLoadError(getLoadErrorMessage(error));
+ setLoadError(describeThrownMessage(error) || DEFAULT_LOAD_ERROR);
})
.finally(() => {
if (registryRequestRef.current === requestId) setLoading(false);
@@ -273,7 +253,7 @@ const ClientsPage = () => {
setConnections([]);
setClusterFilter('ALL');
setSelectedConnection(null);
- setLoadError(getLoadErrorMessage(error));
+ setLoadError(describeThrownMessage(error) || DEFAULT_LOAD_ERROR);
}
})
.finally(() => {
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 26f46df81..ee6472242 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -49,6 +49,7 @@ import {
} from '../../services/messageService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { buildCsv, downloadBlob, downloadCsv, type CsvColumn } from
'../../utils/download';
+import { describeThrownMessage } from '../../utils/apiError';
import { tableScrollX } from '../../utils/table';
const { Text } = Typography;
@@ -58,27 +59,6 @@ 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;
-};
-
export const formatDateTime = (value?: string | number | null): string => {
if (value === undefined || value === null || value === '') return '-';
const d = new Date(value);
@@ -219,7 +199,7 @@ const DLQPage = () => {
})
.catch((error) => {
if (groupRequestIdRef.current === requestId) {
- setLoadError(getErrorMessage(error, DEFAULT_LOAD_ERROR));
+ setLoadError(describeThrownMessage(error) || DEFAULT_LOAD_ERROR);
setLoading(false);
}
});
@@ -296,7 +276,7 @@ const DLQPage = () => {
setRetryError(null);
} catch (error) {
if (retryRequestIdRef.current === requestId) {
- setRetryError(getErrorMessage(error, DEFAULT_RETRY_ERROR));
+ setRetryError(describeThrownMessage(error) || DEFAULT_RETRY_ERROR);
}
} finally {
if (retryRequestIdRef.current === requestId) {
@@ -323,7 +303,7 @@ const DLQPage = () => {
message.success(`已导出 ${group.groupName} 的死信消息(${blob.size} 字节)`);
}
} catch (error) {
- message.error(getErrorMessage(error, '导出死信消息失败,请稍后重试'));
+ message.error(describeThrownMessage(error) || '导出死信消息失败,请稍后重试');
}
};
@@ -367,7 +347,7 @@ const DLQPage = () => {
setDetailPage(page);
} catch (error) {
if (detailRequestIdRef.current === requestId) {
- setDetailError(getErrorMessage(error, '死信消息明细加载失败,请稍后重试'));
+ setDetailError(describeThrownMessage(error) || '死信消息明细加载失败,请稍后重试');
}
} finally {
if (detailRequestIdRef.current === requestId) {
@@ -406,7 +386,7 @@ const DLQPage = () => {
await loadDetailMessages(group, pageToReload, pageSizeToReload);
} catch (error) {
if (detailResendRequestIdRef.current === requestId) {
- setDetailError(getErrorMessage(error, '重发死信消息失败,请稍后重试'));
+ setDetailError(describeThrownMessage(error) || '重发死信消息失败,请稍后重试');
}
} finally {
if (detailResendRequestIdRef.current === requestId) {
@@ -437,7 +417,7 @@ const DLQPage = () => {
);
}
} catch (error) {
- message.error(getErrorMessage(error, '导出死信消息失败,请稍后重试'));
+ message.error(describeThrownMessage(error) || '导出死信消息失败,请稍后重试');
}
};
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 0b85eb017..c57c09bae 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -70,6 +70,7 @@ import {
import { listTopics } from '../../services/topicService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { downloadBlob } from '../../utils/download';
+import { describeThrownMessage } from '../../utils/apiError';
import {
readMessageTraceTopic,
writeMessageTraceTopic,
@@ -89,15 +90,6 @@ const { RangePicker } = DatePicker;
type QueryMode = 'topic' | 'key' | 'msgid' | 'queue';
-type ApiErrorLike = {
- message?: unknown;
- response?: {
- data?: {
- message?: unknown;
- };
- };
-};
-
const QUERY_OPTIONS = [
{ value: 'topic' as const },
{ value: 'key' as const },
@@ -190,18 +182,6 @@ const normalizeMessageQuery = (mode: QueryMode, params:
MessageQuery): MessageQu
return commonParams;
};
-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 diagnosticTagColor: Record<TraceDiagnosticStatus, string> = {
healthy: 'success',
warning: 'warning',
@@ -524,7 +504,7 @@ const MessagePageContent = ({
message.success(t('messagePage.queryCompleted', { total: result.total
}));
} catch (error) {
if (queryGenerationRef.current === requestGeneration) {
- setQueryError(getErrorMessage(error, t('messagePage.queryFailed')));
+ setQueryError(describeThrownMessage(error) ||
t('messagePage.queryFailed'));
}
} finally {
if (queryGenerationRef.current === requestGeneration) {
@@ -612,7 +592,7 @@ const MessagePageContent = ({
setTraceError(null);
} catch (error) {
if (traceGenerationRef.current === requestGeneration) {
- setTraceError(getErrorMessage(error,
t('messagePage.traceLoadFailed')));
+ setTraceError(describeThrownMessage(error) ||
t('messagePage.traceLoadFailed'));
}
} finally {
if (traceGenerationRef.current === requestGeneration) {
@@ -671,7 +651,7 @@ const MessagePageContent = ({
setTraceError(null);
} catch (error) {
if (traceGenerationRef.current === requestGeneration) {
- setTraceError(getErrorMessage(error,
t('messagePage.traceLoadFailed')));
+ setTraceError(describeThrownMessage(error) ||
t('messagePage.traceLoadFailed'));
}
} finally {
if (traceGenerationRef.current === requestGeneration) {
@@ -721,7 +701,7 @@ const MessagePageContent = ({
);
setDirectConsumeOpen(false);
} catch (error) {
- message.error(getErrorMessage(error,
t('messagePage.directConsumeFailed')));
+ message.error(describeThrownMessage(error) ||
t('messagePage.directConsumeFailed'));
} finally {
setDirectConsumeSubmitting(false);
}
diff --git a/web/src/utils/apiError.test.ts b/web/src/utils/apiError.test.ts
new file mode 100644
index 000000000..e2e29a70b
--- /dev/null
+++ b/web/src/utils/apiError.test.ts
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { describeApiError, describeThrownMessage } from './apiError';
+
+type ResponseCarryingError = Error & { response?: { data?: { message?: unknown
} } };
+
+const httpError = (serverMessage: unknown, message = 'Request failed with
status code 500') => {
+ const error: ResponseCarryingError = new Error(message);
+ error.response = { data: { message: serverMessage } };
+ return error;
+};
+
+describe('describeApiError', () => {
+ it('prefers the server-supplied rejection reason', () => {
+ expect(describeApiError(httpError('broker not writable'),
'fallback')).toBe(
+ 'broker not writable',
+ );
+ });
+
+ it('uses the fallback when the server sent no usable message', () => {
+ expect(describeApiError(httpError(' '), 'fallback')).toBe('fallback');
+ expect(describeApiError(httpError(42), 'fallback')).toBe('fallback');
+ expect(describeApiError(new Error('Network Error'),
'fallback')).toBe('fallback');
+ });
+
+ it('never reads a property of a rejection that carries no value', () => {
+ expect(describeApiError(undefined, 'fallback')).toBe('fallback');
+ expect(describeApiError(null, 'fallback')).toBe('fallback');
+ });
+});
+
+describe('describeThrownMessage', () => {
+ it('prefers the server message over the transport message', () => {
+ expect(describeThrownMessage(httpError('topic not found'))).toBe('topic
not found');
+ });
+
+ it('falls back to the thrown message when the server sent none', () => {
+ expect(describeThrownMessage(new Error('Network Error'))).toBe('Network
Error');
+ expect(describeThrownMessage(httpError(''))).toBe('Request failed with
status code 500');
+ });
+
+ it('accepts a bare object thrown without an Error', () => {
+ expect(describeThrownMessage({ message: 'rate limited' })).toBe('rate
limited');
+ });
+
+ it('returns an empty string when nothing usable was thrown', () => {
+ expect(describeThrownMessage(undefined)).toBe('');
+ expect(describeThrownMessage(null)).toBe('');
+ expect(describeThrownMessage({})).toBe('');
+ expect(describeThrownMessage({ message: 42 })).toBe('');
+ expect(describeThrownMessage(new Error(' '))).toBe('');
+ });
+});
diff --git a/web/src/utils/apiError.ts b/web/src/utils/apiError.ts
index 07f63eb1f..1c849c956 100644
--- a/web/src/utils/apiError.ts
+++ b/web/src/utils/apiError.ts
@@ -20,5 +20,9 @@ export function describeThrownMessage(error: unknown): string
{
const serverMessage = describeApiError(error, '');
if (serverMessage) return serverMessage;
if (error instanceof Error && error.message.trim()) return error.message;
+ // Not everything thrown is an Error: a rejected promise can carry a bare
object, and the
+ // page-level extractors this replaced accepted any string `message`.
+ const thrownMessage = (error as { message?: unknown } | null |
undefined)?.message;
+ if (typeof thrownMessage === 'string' && thrownMessage.trim()) return
thrownMessage;
return '';
}