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 b6e1e66ab fix: make cloud instance import resilient to partial 
failures (#2676)
b6e1e66ab is described below

commit b6e1e66abf36834d55cedb27f25fcab1948a9ae5
Author: xdz997 <[email protected]>
AuthorDate: Wed Sep 2 19:50:54 2026 +0800

    fix: make cloud instance import resilient to partial failures (#2676)
    
    * fix: continue cloud instance import after failures
    
    * test: verify cloud import failure summary
    
    * refactor: use typed duplicate instance exception
---
 .../studio/instance/CloudImportResultVO.java       |  12 ++
 ...VO.java => DuplicateInstanceNameException.java} |  26 +--
 .../rocketmq/studio/instance/InstanceService.java  | 217 ++++++++++++++++-----
 .../studio/instance/InstanceServiceTest.java       | 200 +++++++++++++++++++
 web/src/api/instance.test.ts                       |   9 +-
 web/src/api/instance.ts                            |   4 +
 .../pages/instance/__tests__/InstancePage.test.tsx |  49 +++++
 web/src/pages/instance/index.tsx                   |  13 +-
 8 files changed, 460 insertions(+), 70 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/CloudImportResultVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/CloudImportResultVO.java
index 5f453923f..e03acaf1f 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/CloudImportResultVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/CloudImportResultVO.java
@@ -36,5 +36,17 @@ public class CloudImportResultVO {
 
     private int skipped;
 
+    /**
+     * Total number of failures encountered during discovery or import. The 
{@link #failed}
+     * list is intentionally bounded for API and UI safety, so callers must 
use this value for
+     * the complete failure count.
+     */
+    private int failedCount;
+
+    /**
+     * Whether the {@link #failed} list contains only the first bounded 
portion of failures.
+     */
+    private boolean failureDetailsTruncated;
+
     private List<String> failed;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/CloudImportResultVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/DuplicateInstanceNameException.java
similarity index 71%
copy from 
server/src/main/java/org/apache/rocketmq/studio/instance/CloudImportResultVO.java
copy to 
server/src/main/java/org/apache/rocketmq/studio/instance/DuplicateInstanceNameException.java
index 5f453923f..67be42b09 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/CloudImportResultVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/DuplicateInstanceNameException.java
@@ -14,27 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package org.apache.rocketmq.studio.instance;
 
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.util.List;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class CloudImportResultVO {
-
-    private int discovered;
-
-    private int imported;
-
-    private int skipped;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
 
-    private List<String> failed;
+/** Raised when an instance name conflicts with an existing instance. */
+public class DuplicateInstanceNameException extends BusinessException {
+    public DuplicateInstanceNameException(String name) {
+        super(400, "Instance name already exists: " + name);
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index 935f91dbb..f842c6b2f 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -45,9 +45,12 @@ import 
org.springframework.transaction.annotation.Transactional;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
@@ -72,6 +75,8 @@ public class InstanceService {
     static final int COUNT_PARALLELISM = 8;
     static final long COUNT_TIMEOUT_SECONDS = 3;
     private static final int MAX_BATCH_FAILURE_MESSAGE_LENGTH = 500;
+    static final int MAX_CLOUD_IMPORT_FAILURE_DETAILS = 100;
+    static final int MAX_CLOUD_IMPORT_FAILURE_MESSAGE_LENGTH = 500;
 
     private final ExecutorService countExecutor = 
Executors.newFixedThreadPool(COUNT_PARALLELISM, runnable -> {
         Thread thread = new Thread(runnable, "instance-resource-counts");
@@ -198,8 +203,9 @@ public class InstanceService {
     /**
      * Imports every cloud instance visible to the credential by walking all 
catalog regions.
      * Remarks are resolved from the cloud instance detail during creation. 
Instances whose
-     * resolved name already exists are skipped; per-instance failures are 
collected instead
-     * of aborting the batch.
+     * resolved name already exists are skipped; region and per-instance 
failures are collected
+     * instead of aborting the batch. Failure details are bounded while the 
result retains the
+     * complete failure count.
      */
     public CloudImportResultVO importCloudInstances(InstanceVendor vendor, 
Long credentialId) {
         if (vendor == null || vendor == InstanceVendor.APACHE) {
@@ -213,58 +219,177 @@ public class InstanceService {
         if (credential.getVendor() != vendor) {
             throw new BusinessException(400, "Cloud credential vendor does not 
match " + vendor);
         }
-        CloudCatalogProvider catalog = providerRegistry.catalogFor(vendor);
+        CloudImportAccumulator result = new CloudImportAccumulator();
+        CloudCatalogProvider catalog;
+        try {
+            catalog = providerRegistry.catalogFor(vendor);
+        } catch (RuntimeException ex) {
+            result.addFailure("catalog", ex);
+            return finishCloudImport(vendor, credentialId, result);
+        }
+        if (catalog == null) {
+            result.addFailure("catalog", "provider returned no cloud catalog");
+            return finishCloudImport(vendor, credentialId, result);
+        }
 
-        int discovered = 0;
-        int imported = 0;
-        int skipped = 0;
-        List<String> failed = new ArrayList<>();
-        for (CloudRegionVO region : catalog.listRegions(credentialId)) {
-            if (region == null || !StringUtils.hasText(region.getRegionId())) {
+        List<CloudRegionVO> regions;
+        try {
+            regions = catalog.listRegions(credentialId);
+        } catch (RuntimeException ex) {
+            result.addFailure("regions", ex);
+            return finishCloudImport(vendor, credentialId, result);
+        }
+        if (regions == null) {
+            result.addFailure("regions", "catalog returned a null region 
list");
+            return finishCloudImport(vendor, credentialId, result);
+        }
+
+        Set<String> seenRegions = new LinkedHashSet<>();
+        for (CloudRegionVO region : regions) {
+            String regionId = normalizeCloudImportValue(region == null ? null 
: region.getRegionId());
+            if (regionId == null) {
+                result.addFailure("region", "catalog returned an invalid 
region entry");
                 continue;
             }
-            List<CloudInstanceOptionVO> options;
-            try {
-                options = catalog.listCloudInstances(credentialId, 
region.getRegionId(), null);
-            } catch (BusinessException ex) {
-                failed.add(region.getRegionId() + ": " + ex.getMessage());
+            if (!seenRegions.add(regionId)) {
                 continue;
             }
-            for (CloudInstanceOptionVO option : options) {
-                if (option == null || 
!StringUtils.hasText(option.getInstanceId())) {
-                    continue;
-                }
-                discovered++;
-                InstanceVO request = InstanceVO.builder()
-                        .vendor(vendor)
-                        .credentialId(credentialId)
-                        .regionId(region.getRegionId())
-                        .cloudInstanceId(option.getInstanceId())
-                        .name(option.getInstanceId())
-                        .build();
-                try {
-                    createInstance(request);
-                    imported++;
-                } catch (BusinessException ex) {
-                    if (ex.getMessage() != null && 
ex.getMessage().startsWith("Instance name already exists")) {
-                        skipped++;
-                    } else {
-                        failed.add(option.getInstanceId() + ": " + 
ex.getMessage());
-                    }
-                }
+            importCloudRegion(catalog, vendor, credentialId, regionId, result);
+        }
+        return finishCloudImport(vendor, credentialId, result);
+    }
+
+    private void importCloudRegion(CloudCatalogProvider catalog, 
InstanceVendor vendor, Long credentialId,
+                                   String regionId, CloudImportAccumulator 
result) {
+        List<CloudInstanceOptionVO> options;
+        try {
+            options = catalog.listCloudInstances(credentialId, regionId, null);
+        } catch (RuntimeException ex) {
+            result.addFailure(regionId, ex);
+            return;
+        }
+        if (options == null) {
+            result.addFailure(regionId, "catalog returned a null instance 
list");
+            return;
+        }
+
+        for (int index = 0; index < options.size(); index++) {
+            CloudInstanceOptionVO option = options.get(index);
+            String rowTarget = regionId + " row " + (index + 1);
+            if (option == null) {
+                result.addFailure(rowTarget, "catalog returned a null instance 
entry");
+                continue;
+            }
+            String cloudInstanceId = 
normalizeCloudImportValue(option.getInstanceId());
+            if (cloudInstanceId == null) {
+                result.addFailure(rowTarget, "catalog returned an instance 
without an id");
+                continue;
             }
+            if (!result.markDiscovered(regionId, cloudInstanceId)) {
+                continue;
+            }
+            importCloudInstance(vendor, credentialId, regionId, 
cloudInstanceId, result);
+        }
+    }
+
+    private void importCloudInstance(InstanceVendor vendor, Long credentialId,
+                                     String regionId, String cloudInstanceId, 
CloudImportAccumulator result) {
+        InstanceVO request = InstanceVO.builder()
+                .vendor(vendor)
+                .credentialId(credentialId)
+                .regionId(regionId)
+                .cloudInstanceId(cloudInstanceId)
+                .name(cloudInstanceId)
+                .build();
+        try {
+            createInstance(request);
+            result.imported++;
+        } catch (DuplicateInstanceNameException ex) {
+            result.skipped++;
+        } catch (BusinessException ex) {
+            result.addFailure(cloudInstanceId, ex);
+        } catch (RuntimeException ex) {
+            result.addFailure(cloudInstanceId, ex);
         }
+    }
+
+    private CloudImportResultVO finishCloudImport(InstanceVendor vendor, Long 
credentialId,
+                                                  CloudImportAccumulator 
result) {
         log.info("Cloud import finished: vendor={}, credentialId={}, 
discovered={}, imported={}, skipped={}, failed={}",
-                vendor, credentialId, discovered, imported, skipped, 
failed.size());
+                vendor, credentialId, result.discovered, result.imported, 
result.skipped, result.failedCount);
         recordAudit("IMPORT_CLOUD_INSTANCES", "INSTANCE", 
String.valueOf(credentialId), null,
-                "vendor=" + vendor + ", imported=" + imported + ", skipped=" + 
skipped
-                        + ", failed=" + failed.size());
-        return CloudImportResultVO.builder()
-                .discovered(discovered)
-                .imported(imported)
-                .skipped(skipped)
-                .failed(failed)
-                .build();
+                "vendor=" + vendor + ", imported=" + result.imported + ", 
skipped=" + result.skipped
+                        + ", failed=" + result.failedCount);
+        return result.toValue();
+    }
+
+    private String normalizeCloudImportValue(String value) {
+        return StringUtils.hasText(value) ? value.trim() : null;
+    }
+
+    private static String cloudImportFailureMessage(Throwable failure) {
+        String message = failure == null ? null : failure.getMessage();
+        if (!StringUtils.hasText(message)) {
+            message = failure == null ? "unknown failure" : 
failure.getClass().getSimpleName();
+        }
+        return boundedCloudImportText(message, 
MAX_CLOUD_IMPORT_FAILURE_MESSAGE_LENGTH);
+    }
+
+    private static String boundedCloudImportText(String value, int maxLength) {
+        String singleLine = value == null ? "" : value.replaceAll("\\s+", " 
").trim();
+        if (singleLine.length() <= maxLength) {
+            return singleLine;
+        }
+        return singleLine.substring(0, maxLength - 1) + "…";
+    }
+
+    private static final class CloudImportAccumulator {
+
+        private final List<String> failed = new ArrayList<>();
+        private final Set<CloudInstanceKey> discoveredKeys = new HashSet<>();
+        private int discovered;
+        private int imported;
+        private int skipped;
+        private int failedCount;
+        private boolean failureDetailsTruncated;
+
+        private boolean markDiscovered(String regionId, String 
cloudInstanceId) {
+            if (!discoveredKeys.add(new CloudInstanceKey(regionId, 
cloudInstanceId))) {
+                return false;
+            }
+            discovered++;
+            return true;
+        }
+
+        private void addFailure(String target, Throwable failure) {
+            addFailure(target, cloudImportFailureMessage(failure));
+        }
+
+        private void addFailure(String target, String message) {
+            failedCount++;
+            if (failed.size() < MAX_CLOUD_IMPORT_FAILURE_DETAILS) {
+                String safeTarget = boundedCloudImportText(target, 
MAX_CLOUD_IMPORT_FAILURE_MESSAGE_LENGTH);
+                String safeMessage = boundedCloudImportText(message, 
MAX_CLOUD_IMPORT_FAILURE_MESSAGE_LENGTH);
+                failed.add(boundedCloudImportText(safeTarget + ": " + 
safeMessage,
+                        MAX_CLOUD_IMPORT_FAILURE_MESSAGE_LENGTH));
+            } else {
+                failureDetailsTruncated = true;
+            }
+        }
+
+        private CloudImportResultVO toValue() {
+            return CloudImportResultVO.builder()
+                    .discovered(discovered)
+                    .imported(imported)
+                    .skipped(skipped)
+                    .failedCount(failedCount)
+                    .failureDetailsTruncated(failureDetailsTruncated)
+                    .failed(List.copyOf(failed))
+                    .build();
+        }
+    }
+
+    private record CloudInstanceKey(String regionId, String cloudInstanceId) {
     }
 
     private void requireUniqueInstanceName(String name, Long excludeId) {
@@ -273,7 +398,7 @@ public class InstanceService {
         }
         instanceRepository.findByName(name).ifPresent(existing -> {
             if (excludeId == null || !excludeId.equals(existing.getId())) {
-                throw new BusinessException(400, "Instance name already 
exists: " + name);
+                throw new DuplicateInstanceNameException(name);
             }
         });
     }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index 4f77d9222..8c01617f4 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -55,6 +55,7 @@ import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
@@ -1255,6 +1256,181 @@ class InstanceServiceTest {
         assertThat(saved.getValue().getRegionId()).isEqualTo("cn-hangzhou");
     }
 
+    @Test
+    void importCloudInstancesShouldContinueAfterUnexpectedRegionFailureTest() {
+        CloudCatalogProvider catalog = prepareAliyunCatalog();
+        CloudRegionVO broken = new CloudRegionVO("cn-broken", "Broken");
+        CloudRegionVO working = new CloudRegionVO("  cn-working  ", "Working");
+        when(catalog.listRegions(1L)).thenReturn(List.of(broken, working));
+        when(catalog.listCloudInstances(1L, "cn-broken", null))
+                .thenThrow(new IllegalStateException("regional outage"));
+
+        CloudInstanceOptionVO option = new CloudInstanceOptionVO();
+        option.setInstanceId("  rmq-working  ");
+        when(catalog.listCloudInstances(1L, "cn-working", 
null)).thenReturn(List.of(option));
+        when(catalog.getCloudInstance(1L, "cn-working", "rmq-working"))
+                .thenReturn(cloudDetail("rmq-working", "vpc-working:8080"));
+        
when(instanceRepository.findByName("rmq-working")).thenReturn(Optional.empty());
+        
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation -> 
invocation.getArgument(0));
+
+        CloudImportResultVO result = 
instanceService.importCloudInstances(InstanceVendor.ALIYUN, 1L);
+
+        assertThat(result.getDiscovered()).isEqualTo(1);
+        assertThat(result.getImported()).isEqualTo(1);
+        assertThat(result.getSkipped()).isZero();
+        assertThat(result.getFailedCount()).isEqualTo(1);
+        assertThat(result.isFailureDetailsTruncated()).isFalse();
+        assertThat(result.getFailed()).containsExactly("cn-broken: regional 
outage");
+        verify(catalog).listCloudInstances(1L, "cn-working", null);
+        verify(catalog).getCloudInstance(1L, "cn-working", "rmq-working");
+        verify(operationAuditService).record(eq("IMPORT_CLOUD_INSTANCES"), 
eq("INSTANCE"), eq("1"), eq(null),
+                argThat(detail -> detail.contains("imported=1") && 
detail.contains("failed=1")),
+                eq("SUCCESS"), eq(null));
+    }
+
+    @Test
+    void 
importCloudInstancesShouldContinueAfterUnexpectedInstanceFailuresTest() {
+        CloudCatalogProvider catalog = prepareAliyunCatalog();
+        CloudRegionVO region = new CloudRegionVO("cn-hangzhou", "Hangzhou");
+        when(catalog.listRegions(1L)).thenReturn(List.of(region));
+
+        CloudInstanceOptionVO detailFailure = 
cloudOption("rmq-detail-failure");
+        CloudInstanceOptionVO saveFailure = cloudOption("rmq-save-failure");
+        CloudInstanceOptionVO successful = cloudOption("rmq-success");
+        when(catalog.listCloudInstances(1L, "cn-hangzhou", null))
+                .thenReturn(List.of(detailFailure, saveFailure, successful));
+        when(catalog.getCloudInstance(1L, "cn-hangzhou", "rmq-detail-failure"))
+                .thenThrow(new IllegalStateException("detail lookup failed"));
+        when(catalog.getCloudInstance(1L, "cn-hangzhou", "rmq-save-failure"))
+                .thenReturn(cloudDetail("rmq-save-failure", "vpc-save:8080"));
+        when(catalog.getCloudInstance(1L, "cn-hangzhou", "rmq-success"))
+                .thenReturn(cloudDetail("rmq-success", "vpc-success:8080"));
+        
when(instanceRepository.findByName(anyString())).thenReturn(Optional.empty());
+        String longFailure = "persistence failure ".repeat(60);
+        
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation -> {
+            InstanceVO instance = invocation.getArgument(0);
+            if ("rmq-save-failure".equals(instance.getName())) {
+                throw new IllegalStateException(longFailure);
+            }
+            return instance;
+        });
+
+        CloudImportResultVO result = 
instanceService.importCloudInstances(InstanceVendor.ALIYUN, 1L);
+
+        assertThat(result.getDiscovered()).isEqualTo(3);
+        assertThat(result.getImported()).isEqualTo(1);
+        assertThat(result.getSkipped()).isZero();
+        assertThat(result.getFailedCount()).isEqualTo(2);
+        assertThat(result.getFailed()).hasSize(2)
+                .contains("rmq-detail-failure: detail lookup failed")
+                .anySatisfy(failure -> assertThat(failure)
+                        .startsWith("rmq-save-failure: ")
+                        .endsWith("…")
+                        .hasSizeLessThan(550));
+        verify(catalog).getCloudInstance(1L, "cn-hangzhou", 
"rmq-detail-failure");
+        verify(catalog).getCloudInstance(1L, "cn-hangzhou", 
"rmq-save-failure");
+        verify(catalog).getCloudInstance(1L, "cn-hangzhou", "rmq-success");
+        verify(instanceRepository, times(2)).save(any(InstanceVO.class));
+    }
+
+    @Test
+    void importCloudInstancesShouldHandleNullCatalogResponsesTest() {
+        CloudCatalogProvider catalog = prepareAliyunCatalog();
+        when(catalog.listRegions(1L)).thenReturn(List.of(new 
CloudRegionVO("cn-empty", "Empty")));
+        when(catalog.listCloudInstances(1L, "cn-empty", 
null)).thenReturn(null);
+
+        CloudImportResultVO result = 
instanceService.importCloudInstances(InstanceVendor.ALIYUN, 1L);
+
+        assertThat(result.getDiscovered()).isZero();
+        assertThat(result.getImported()).isZero();
+        assertThat(result.getFailedCount()).isEqualTo(1);
+        assertThat(result.getFailed()).containsExactly("cn-empty: catalog 
returned a null instance list");
+        assertThat(result.isFailureDetailsTruncated()).isFalse();
+        verify(catalog).listCloudInstances(1L, "cn-empty", null);
+        verifyNoInteractions(instanceRepository);
+    }
+
+    @Test
+    void importCloudInstancesShouldBoundMalformedCatalogFailuresTest() {
+        CloudCatalogProvider catalog = prepareAliyunCatalog();
+        CloudRegionVO blank = new CloudRegionVO("  ", "Blank");
+        List<CloudRegionVO> regions = new ArrayList<>();
+        regions.add(null);
+        regions.add(blank);
+        regions.add(new CloudRegionVO("cn-malformed", "Malformed"));
+        when(catalog.listRegions(1L)).thenReturn(regions);
+
+        List<CloudInstanceOptionVO> options = new ArrayList<>();
+        for (int i = 0; i < 105; i++) {
+            options.add(cloudOption(" "));
+        }
+        when(catalog.listCloudInstances(1L, "cn-malformed", 
null)).thenReturn(options);
+
+        CloudImportResultVO result = 
instanceService.importCloudInstances(InstanceVendor.ALIYUN, 1L);
+
+        assertThat(result.getDiscovered()).isZero();
+        assertThat(result.getImported()).isZero();
+        assertThat(result.getSkipped()).isZero();
+        assertThat(result.getFailedCount()).isEqualTo(107);
+        
assertThat(result.getFailed()).hasSize(InstanceService.MAX_CLOUD_IMPORT_FAILURE_DETAILS);
+        assertThat(result.isFailureDetailsTruncated()).isTrue();
+        assertThat(result.getFailed()).first().isEqualTo("region: catalog 
returned an invalid region entry");
+        verify(catalog).listCloudInstances(1L, "cn-malformed", null);
+        verifyNoInteractions(instanceRepository);
+    }
+
+    @Test
+    void 
importCloudInstancesShouldReturnPartialResultWhenRegionDiscoveryFailsTest() {
+        CloudCatalogProvider catalog = prepareAliyunCatalog();
+        when(catalog.listRegions(1L)).thenThrow(new 
IllegalStateException("region discovery unavailable"));
+
+        CloudImportResultVO result = 
instanceService.importCloudInstances(InstanceVendor.ALIYUN, 1L);
+
+        assertThat(result.getDiscovered()).isZero();
+        assertThat(result.getImported()).isZero();
+        assertThat(result.getFailedCount()).isEqualTo(1);
+        assertThat(result.getFailed()).containsExactly("regions: region 
discovery unavailable");
+        verify(catalog, never()).listCloudInstances(any(Long.class), 
anyString(), any());
+        verifyNoInteractions(instanceRepository);
+    }
+
+    @Test
+    void importCloudInstancesShouldReportMissingCatalogProviderTest() {
+        CloudCredentialVO credential = new CloudCredentialVO();
+        credential.setId(1L);
+        credential.setVendor(InstanceVendor.ALIYUN);
+        
when(cloudCredentialRepository.findById(1L)).thenReturn(Optional.of(credential));
+        
when(providerRegistry.catalogFor(InstanceVendor.ALIYUN)).thenReturn(null);
+
+        CloudImportResultVO result = 
instanceService.importCloudInstances(InstanceVendor.ALIYUN, 1L);
+
+        assertThat(result.getDiscovered()).isZero();
+        assertThat(result.getImported()).isZero();
+        assertThat(result.getFailedCount()).isEqualTo(1);
+        assertThat(result.getFailed()).containsExactly("catalog: provider 
returned no cloud catalog");
+        verifyNoInteractions(instanceRepository);
+    }
+
+    @Test
+    void importCloudInstancesShouldDeduplicateCatalogRowsTest() {
+        CloudCatalogProvider catalog = prepareAliyunCatalog();
+        when(catalog.listRegions(1L)).thenReturn(List.of(new 
CloudRegionVO("cn-hangzhou", "Hangzhou")));
+        when(catalog.listCloudInstances(1L, "cn-hangzhou", null)).thenReturn(
+                List.of(cloudOption(" rmq-duplicate "), 
cloudOption("rmq-duplicate")));
+        when(catalog.getCloudInstance(1L, "cn-hangzhou", "rmq-duplicate"))
+                .thenReturn(cloudDetail("rmq-duplicate", 
"vpc-duplicate:8080"));
+        
when(instanceRepository.findByName("rmq-duplicate")).thenReturn(Optional.empty());
+        
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation -> 
invocation.getArgument(0));
+
+        CloudImportResultVO result = 
instanceService.importCloudInstances(InstanceVendor.ALIYUN, 1L);
+
+        assertThat(result.getDiscovered()).isEqualTo(1);
+        assertThat(result.getImported()).isEqualTo(1);
+        assertThat(result.getFailedCount()).isZero();
+        verify(catalog, times(1)).getCloudInstance(1L, "cn-hangzhou", 
"rmq-duplicate");
+        verify(instanceRepository, times(1)).save(any(InstanceVO.class));
+    }
+
     @Test
     void createInstanceShouldSkipNullCloudEndpointEntries() {
         InstanceVO instance = InstanceVO.builder()
@@ -1368,4 +1544,28 @@ class InstanceServiceTest {
         assertThat(updated.getRemark()).isEqualTo("updated");
         assertThat(updated.getCloudInstanceId()).isEqualTo("rmq-cn-xxx");
     }
+
+    private CloudCatalogProvider prepareAliyunCatalog() {
+        CloudCredentialVO credential = new CloudCredentialVO();
+        credential.setId(1L);
+        credential.setVendor(InstanceVendor.ALIYUN);
+        
when(cloudCredentialRepository.findById(1L)).thenReturn(Optional.of(credential));
+        CloudCatalogProvider catalog = 
org.mockito.Mockito.mock(CloudCatalogProvider.class);
+        
when(providerRegistry.catalogFor(InstanceVendor.ALIYUN)).thenReturn(catalog);
+        return catalog;
+    }
+
+    private CloudInstanceOptionVO cloudOption(String instanceId) {
+        CloudInstanceOptionVO option = new CloudInstanceOptionVO();
+        option.setInstanceId(instanceId);
+        return option;
+    }
+
+    private CloudInstanceDetailVO cloudDetail(String instanceId, String 
endpoint) {
+        CloudInstanceDetailVO detail = new CloudInstanceDetailVO();
+        detail.setInstanceId(instanceId);
+        detail.setInstanceName(instanceId + "-name");
+        detail.setEndpoints(List.of(new 
CloudInstanceDetailVO.CloudEndpoint("TCP_VPC", endpoint)));
+        return detail;
+    }
 }
diff --git a/web/src/api/instance.test.ts b/web/src/api/instance.test.ts
index 9d7db2276..4360106dc 100644
--- a/web/src/api/instance.test.ts
+++ b/web/src/api/instance.test.ts
@@ -111,7 +111,14 @@ describe('instance API', () => {
   });
 
   it('posts cloud import requests for cloud vendors', async () => {
-    const result = { discovered: 4, imported: 1, skipped: 3, failed: [] };
+    const result = {
+      discovered: 4,
+      imported: 1,
+      skipped: 3,
+      failed: [],
+      failedCount: 0,
+      failureDetailsTruncated: false,
+    };
     mock.onPost('/instances/import-cloud').reply((config) => {
       expect(JSON.parse(config.data)).toEqual({ vendor: 'TENCENT', 
credentialId: 201 });
       return [200, { code: 200, data: result }];
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index 33e749b57..12c87bb13 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -118,6 +118,10 @@ export interface CloudImportResult {
   imported: number;
   skipped: number;
   failed: string[];
+  /** Total failures; older servers may omit this field. */
+  failedCount?: number;
+  /** Whether the server returned only a bounded subset of failure details. */
+  failureDetailsTruncated?: boolean;
 }
 
 export async function deleteInstance(instanceId: string) {
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx 
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index e1d6adb58..b0c7fed96 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -425,6 +425,8 @@ describe('InstancePage', () => {
       imported: 2,
       skipped: 1,
       failed: [],
+      failedCount: 0,
+      failureDetailsTruncated: false,
     });
 
     renderPage();
@@ -475,6 +477,8 @@ describe('InstancePage', () => {
       imported: 1,
       skipped: 3,
       failed: [],
+      failedCount: 0,
+      failureDetailsTruncated: false,
     });
 
     renderPage();
@@ -511,6 +515,51 @@ describe('InstancePage', () => {
     ).toBeInTheDocument();
   });
 
+  it('shows the total cloud import failure count when details are truncated', 
async () => {
+    const user = userEvent.setup();
+    vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
+      cloudCredentialPage([
+        {
+          id: 101,
+          name: 'prod-account',
+          vendor: 'ALIYUN',
+          accessKey: 'LTAI-prod',
+          gmtCreate: '2026-01-01T00:00:00Z',
+        },
+      ]),
+    );
+    vi.mocked(instanceService.importCloudInstances).mockResolvedValue({
+      discovered: 4,
+      imported: 0,
+      skipped: 0,
+      failed: ['cn-broken: provider unavailable'],
+      failedCount: 4,
+      failureDetailsTruncated: true,
+    });
+
+    renderPage();
+    expect(await screen.findByText('production-proxy')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /添加实例/ }));
+    const dialog = await screen.findByRole('dialog');
+    await user.click(within(dialog).getByRole('tab', { name: /Aliyun 版/ }));
+    await waitFor(() => 
expect(cloudCredentialApi.listCloudCredentials).toHaveBeenCalled());
+
+    const importButton = within(dialog).getByRole('button', { name: /一键导入/ });
+    const credentialSelect = within(dialog).getAllByRole('combobox')[0];
+    fireEvent.mouseDown(credentialSelect.parentElement!);
+    await user.click(
+      await screen.findByText(/prod-account/, { selector: 
'.ant-select-item-option-content' }),
+    );
+    await waitFor(() => expect(importButton).toBeEnabled());
+    await user.click(importButton);
+
+    expect(
+      await screen.findByText(
+        /导入未完成:新导入 0 个,已存在跳过 0 个,失败 4 个(仅显示前 1 条):cn-broken: provider 
unavailable/,
+      ),
+    ).toBeInTheDocument();
+  });
+
   it('ignores a stale region response after the cloud credential changes', 
async () => {
     const user = userEvent.setup();
     const oldRegions = deferred<Array<{ regionId: string; regionName: string 
}>>();
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 093cabd84..5399cf97d 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -357,12 +357,19 @@ const InstancePage = () => {
     try {
       const result = await importCloudInstances({ vendor, credentialId });
       await loadInstances();
+      const failedCount = result.failedCount ?? result.failed.length;
       const summary =
         result.imported > 0
           ? `导入完成:共同步 ${result.imported + result.skipped} 个实例(新导入 
${result.imported},已存在跳过 ${result.skipped})`
-          : `云上实例均已在 Studio 中(共 ${result.skipped} 个),无需重复导入`;
-      if (result.failed.length > 0) {
-        message.warning(`${summary},失败 ${result.failed.length} 
个:${result.failed.join(';')}`);
+          : failedCount > 0
+            ? `导入未完成:新导入 ${result.imported} 个,已存在跳过 ${result.skipped} 个`
+            : `云上实例均已在 Studio 中(共 ${result.skipped} 个),无需重复导入`;
+      if (failedCount > 0) {
+        const details = result.failed.length > 0 ? 
`:${result.failed.join(';')}` : '';
+        const omitted = result.failureDetailsTruncated
+          ? `(仅显示前 ${result.failed.length} 条)`
+          : '';
+        message.warning(`${summary},失败 ${failedCount} 个${omitted}${details}`);
       } else {
         message.success(summary);
       }

Reply via email to