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 bf743cc4b fix(alert): surface import conflict details (#4334)
bf743cc4b is described below

commit bf743cc4b0d1f4ddb14d6c9f0950f3353332c06e
Author: aias00 <[email protected]>
AuthorDate: Wed Sep 16 16:09:59 2026 +0800

    fix(alert): surface import conflict details (#4334)
    
    Signed-off-by: liuhy <[email protected]>
---
 .../rocketmq/studio/ops/alert/AlertService.java    | 29 ++++++++++++++++------
 .../studio/ops/alert/AlertServiceTest.java         |  4 +--
 web/src/pages/instance/index.tsx                   |  7 +-----
 web/src/pages/ops/__tests__/AlertsPage.test.tsx    | 24 ++++++++++++++++++
 web/src/pages/ops/alerts.tsx                       |  5 ++--
 web/src/utils/apiError.ts                          |  9 +++++++
 6 files changed, 60 insertions(+), 18 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index 1f2aa3f4f..4d8b7ef8c 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -227,12 +227,9 @@ public class AlertService {
     }
 
     private void rejectDuplicateSemanticRule(AlertRuleVO rule, Long 
excludedId) {
-        String fingerprint = AlertRuleSemanticFingerprint.of(rule);
-        boolean duplicate = alertRepository.findAllRules().stream()
-                .filter(candidate -> !Objects.equals(candidate.getId(), 
excludedId))
-                .anyMatch(candidate -> 
AlertRuleSemanticFingerprint.of(candidate).equals(fingerprint));
-        if (duplicate) {
-            throw new BusinessException(409, "An alert rule with the same 
evaluation conditions already exists");
+        AlertRuleVO duplicate = findDuplicateSemanticRule(rule, excludedId);
+        if (duplicate != null) {
+            throw duplicateRuleException(duplicate);
         }
     }
 
@@ -240,7 +237,7 @@ public class AlertService {
         try {
             return alertRepository.insertRule(rule);
         } catch (DuplicateKeyException duplicate) {
-            throw new BusinessException(409, "An alert rule with the same 
evaluation conditions already exists");
+            throw duplicateRuleException(findDuplicateSemanticRule(rule, 
null));
         }
     }
 
@@ -248,10 +245,26 @@ public class AlertService {
         try {
             return alertRepository.replaceRule(rule);
         } catch (DuplicateKeyException duplicate) {
-            throw new BusinessException(409, "An alert rule with the same 
evaluation conditions already exists");
+            throw duplicateRuleException(findDuplicateSemanticRule(rule, 
rule.getId()));
         }
     }
 
+    private AlertRuleVO findDuplicateSemanticRule(AlertRuleVO rule, Long 
excludedId) {
+        String fingerprint = AlertRuleSemanticFingerprint.of(rule);
+        return alertRepository.findAllRules().stream()
+                .filter(candidate -> !Objects.equals(candidate.getId(), 
excludedId))
+                .filter(candidate -> 
AlertRuleSemanticFingerprint.of(candidate).equals(fingerprint))
+                .findFirst()
+                .orElse(null);
+    }
+
+    private static BusinessException duplicateRuleException(AlertRuleVO 
existing) {
+        String suffix = existing != null && 
StringUtils.hasText(existing.getName())
+                ? ": " + existing.getName() : "";
+        return new BusinessException(409,
+                "An alert rule with the same evaluation conditions already 
exists" + suffix);
+    }
+
     private void requireDomain(AlertDomain domain) {
         if (domain == null) {
             throw new BusinessException(400, "Alert domain is required");
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
index 46c95023b..02a32d34b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
@@ -996,7 +996,7 @@ class AlertServiceTest {
 
         assertThatThrownBy(() -> alertService.createRule(duplicate))
                 .isInstanceOf(BusinessException.class)
-                .hasMessage("An alert rule with the same evaluation conditions 
already exists")
+                .hasMessage("An alert rule with the same evaluation conditions 
already exists: Existing rule")
                 .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(409));
 
         verify(alertRepository, never()).insertRule(any());
@@ -1030,7 +1030,7 @@ class AlertServiceTest {
 
         assertThatThrownBy(() -> alertService.updateRule(update))
                 .isInstanceOf(BusinessException.class)
-                .hasMessage("An alert rule with the same evaluation conditions 
already exists")
+                .hasMessage("An alert rule with the same evaluation conditions 
already exists: Existing")
                 .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(409));
 
         verify(alertRepository, never()).replaceRule(any());
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 146013c65..0061354c5 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -47,6 +47,7 @@ import {
   type CloudRegion,
 } from '../../api/aliyunCatalog';
 import { listTencentInstances, listTencentRegions } from 
'../../api/tencentCatalog';
+import { describeApiError } from '../../utils/apiError';
 import { formatDateTime } from '../../utils/format';
 import { tableScrollX } from '../../utils/table';
 import {
@@ -92,12 +93,6 @@ const APACHE_ACCESS_TYPE_OPTIONS = [
   { value: 'DIRECT', labelKey: 'instance.directMode' },
 ] as const;
 
-function describeApiError(error: unknown, fallback: string): string {
-  const serverMessage = (error as { response?: { data?: { message?: unknown } 
} })?.response?.data
-    ?.message;
-  return typeof serverMessage === 'string' && serverMessage.trim() ? 
serverMessage : fallback;
-}
-
 type InstanceTypeFilter = 'ALL' | Instance['type'];
 
 function compareResourceCounts(
diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
index 9f4f34431..0291f2ace 100644
--- a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -31,6 +31,7 @@ import {
   listAlertRulesPage,
   listAlertRuleRuntime,
   listNativeAlertMetrics,
+  importAlertRulesTransfer,
   toggleAlertRule,
 } from '../../../services/opsService';
 
@@ -758,6 +759,29 @@ describe('AlertsPage', () => {
     );
   });
 
+  it('shows an actionable server error when alert rule import conflicts', 
async () => {
+    const serverMessage =
+      'An alert rule with the same evaluation conditions already exists: 
Existing rule';
+    vi.mocked(importAlertRulesTransfer).mockRejectedValue({
+      response: { data: { code: 409, message: serverMessage } },
+    });
+    const user = userEvent.setup();
+    const { container } = renderPage();
+    await screen.findByText('Broker disk usage');
+    const input = 
container.querySelector<HTMLInputElement>('input[type="file"]');
+    if (!input) throw new Error('Alert rule import input not found');
+    const file = new File(
+      [JSON.stringify({ version: 1, domain: 'CLUSTER', rules: [] })],
+      'cluster-alert-rules.json',
+      { type: 'application/json' },
+    );
+
+    await user.upload(input, file);
+
+    expect(await screen.findByText(serverMessage)).toBeInTheDocument();
+    expect(screen.queryByText('导入失败,请选择当前页面导出的规则文件')).not.toBeInTheDocument();
+  });
+
   it('disables other alert rule mutations while a bulk action is running', 
async () => {
     let resolveToggle:
       | ((result: {
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index d88c97876..5b6085360 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -76,6 +76,7 @@ import {
   type AlertTemplatePreviewIssue,
 } from '../../utils/alertTemplatePreview';
 import type { TextAreaRef } from 'antd/es/input/TextArea';
+import { describeApiError } from '../../utils/apiError';
 const { TextArea } = Input;
 
 const channelColors: Record<string, string> = {
@@ -448,8 +449,8 @@ const AlertsPage = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
       setPage(1);
       refreshRules();
       message.success(t('alerts.importSuccess', { count: imported.length }));
-    } catch {
-      message.error(t('alerts.importFailed'));
+    } catch (error) {
+      message.error(describeApiError(error, t('alerts.importFailed')));
     } finally {
       setTransferringRules(false);
     }
diff --git a/web/src/utils/apiError.ts b/web/src/utils/apiError.ts
new file mode 100644
index 000000000..2d59c8217
--- /dev/null
+++ b/web/src/utils/apiError.ts
@@ -0,0 +1,9 @@
+/**
+ * Extracts the server-supplied message from an API error so callers can 
surface the
+ * concrete rejection reason instead of a generic fallback.
+ */
+export function describeApiError(error: unknown, fallback: string): string {
+  const serverMessage = (error as { response?: { data?: { message?: unknown } 
} })?.response?.data
+    ?.message;
+  return typeof serverMessage === 'string' && serverMessage.trim() ? 
serverMessage : fallback;
+}

Reply via email to