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 '';
 }

Reply via email to