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