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 35b479152 perf(instance): faster list, localized regions, loading
states and region-aware sorting
35b479152 is described below
commit 35b4791520140dfd79d96c7c5778454495f60dff
Author: lizhimins <[email protected]>
AuthorDate: Thu Aug 20 10:41:53 2026 +0800
perf(instance): faster list, localized regions, loading states and
region-aware sorting
Resource counts fan out on a bounded executor with a per-instance
timeout instead of blocking the list serially, and the frontend dedupes
concurrent list requests. Region display names load from
regions.properties at startup (regionName on InstanceVO) and the
region column leads the table; the table drops fixed-width forcing so
it always fits its container. Topic/group pages keep the spinner
visible while the selected instance resolves, and list ordering is
APACHE first, then cloud vendors by region and instance id with no
frontend re-sort.
---
.../rocketmq/studio/common/util/RegionNames.java | 69 ++++++++++++++++++++++
.../rocketmq/studio/instance/InstanceService.java | 61 ++++++++++++++++++-
.../rocketmq/studio/instance/InstanceVO.java | 1 +
server/src/main/resources/regions.properties | 61 +++++++++++++++++++
.../studio/common/util/RegionNamesTest.java | 61 +++++++++++++++++++
.../studio/instance/InstanceServiceTest.java | 31 ++++++++--
web/src/api/instance.ts | 1 +
web/src/hooks/useInstanceFilter.ts | 9 ++-
.../pages/instance/__tests__/ConsumerPage.test.tsx | 17 +++++-
.../pages/instance/__tests__/InstancePage.test.tsx | 31 ++++++++--
web/src/pages/instance/consumer.tsx | 15 ++++-
web/src/pages/instance/index.tsx | 38 ++++++++----
web/src/pages/instance/topic.tsx | 15 +++--
web/src/services/instanceService.test.ts | 52 +++++++++++++++-
web/src/services/instanceService.ts | 15 ++++-
15 files changed, 443 insertions(+), 34 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/common/util/RegionNames.java
b/server/src/main/java/org/apache/rocketmq/studio/common/util/RegionNames.java
new file mode 100644
index 000000000..c6eb163d0
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/common/util/RegionNames.java
@@ -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.
+ */
+
+package org.apache.rocketmq.studio.common.util;
+
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+
+/**
+ * Region display names loaded once at application startup from
regions.properties
+ * (baseline: RocketMQ 5.0 region list). Unknown or blank region ids resolve
to themselves.
+ */
+@Slf4j
+@Component
+public class RegionNames {
+
+ private static final String CONFIG_FILE = "regions.properties";
+
+ private Map<String, String> names = Collections.emptyMap();
+
+ @PostConstruct
+ void load() {
+ Properties properties = new Properties();
+ try (InputStream input = new
ClassPathResource(CONFIG_FILE).getInputStream()) {
+ properties.load(input);
+ } catch (IOException ex) {
+ log.warn("Failed to load {}, region names fall back to raw ids:
{}", CONFIG_FILE, ex.getMessage());
+ return;
+ }
+ Map<String, String> loaded = new HashMap<>();
+ properties.forEach((key, value) -> {
+ if (key != null && value != null && !value.toString().isBlank()) {
+ loaded.put(key.toString().trim(), value.toString().trim());
+ }
+ });
+ names = Collections.unmodifiableMap(loaded);
+ log.info("Loaded {} region display names from {}", names.size(),
CONFIG_FILE);
+ }
+
+ public String resolve(String regionId) {
+ if (regionId == null || regionId.isBlank()) {
+ return regionId;
+ }
+ return names.getOrDefault(regionId.trim(), regionId);
+ }
+}
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 40f8c8cfb..7b4675eb1 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
@@ -26,6 +26,7 @@ import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.util.RegionNames;
import org.apache.rocketmq.studio.provider.CloudCatalogProvider;
import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
import org.apache.rocketmq.studio.provider.CloudInstanceOptionVO;
@@ -34,6 +35,7 @@ import org.apache.rocketmq.studio.provider.InstanceProvider;
import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
import org.apache.rocketmq.studio.settings.DataSourceVO;
import org.apache.rocketmq.studio.settings.SettingsRepository;
+import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -45,6 +47,12 @@ import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
@Slf4j
@Service
@@ -57,6 +65,21 @@ public class InstanceService {
private final MqAdminExtFactory adminFactory;
private final OperationAuditService operationAuditService;
private final SettingsRepository settingsRepository;
+ private final RegionNames regionNames;
+
+ static final int COUNT_PARALLELISM = 8;
+ static final long COUNT_TIMEOUT_SECONDS = 3;
+
+ private final ExecutorService countExecutor =
Executors.newFixedThreadPool(COUNT_PARALLELISM, runnable -> {
+ Thread thread = new Thread(runnable, "instance-resource-counts");
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ @PreDestroy
+ void shutdownCountExecutor() {
+ countExecutor.shutdownNow();
+ }
public List<InstanceVO> listInstances(InstanceType type, String search) {
log.debug("Listing instances, type={}, search={}", type, search);
@@ -72,16 +95,52 @@ public class InstanceService {
} else {
instances = instanceRepository.findAll();
}
- instances.forEach(this::fillCounts);
+ fillCountsInParallel(instances);
+ instances.forEach(instance ->
instance.setRegionName(regionNames.resolve(instance.getRegionId())));
List<InstanceVO> sorted = new ArrayList<>(instances);
sorted.sort(Comparator
.comparing((InstanceVO instance) ->
instance.getVendor() == null || instance.getVendor()
== InstanceVendor.APACHE ? 0 : 1)
.thenComparing(instance -> instance.getVendor() == null ? "" :
instance.getVendor().name())
+ .thenComparing(instance -> instance.getRegionId() == null ? ""
: instance.getRegionId())
.thenComparing(InstanceVO::getName,
String.CASE_INSENSITIVE_ORDER));
return sorted;
}
+ /**
+ * Fans out per-instance resource counts on a bounded executor. Cloud
vendors resolve counts
+ * through remote OpenAPIs, so a slow instance only degrades its own row
(counts marked
+ * unavailable) instead of blocking the whole list response.
+ */
+ private void fillCountsInParallel(List<InstanceVO> instances) {
+ if (instances.isEmpty()) {
+ return;
+ }
+ List<InstanceVO> pending = new ArrayList<>(instances);
+ List<Future<?>> futures = new ArrayList<>(instances.size());
+ for (InstanceVO instance : pending) {
+ futures.add(countExecutor.submit(() -> fillCounts(instance)));
+ }
+ for (int i = 0; i < pending.size(); i++) {
+ InstanceVO instance = pending.get(i);
+ try {
+ futures.get(i).get(COUNT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } catch (TimeoutException ex) {
+ futures.get(i).cancel(true);
+ instance.setResourceCountsAvailable(false);
+ log.warn("Resource counts timed out after {}s for instance {}",
+ COUNT_TIMEOUT_SECONDS, instance.getId());
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ instance.setResourceCountsAvailable(false);
+ } catch (ExecutionException ex) {
+ instance.setResourceCountsAvailable(false);
+ log.warn("Failed to load resource counts for instance {}: {}",
+ instance.getId(), ex.getMessage());
+ }
+ }
+ }
+
/**
* Resource counts live on the vendor side (cloud APIs) or in the local
tables (Apache),
* so resolve them uniformly through the vendor provider.
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceVO.java
index 78e6040e0..60a14d155 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceVO.java
@@ -41,6 +41,7 @@ public class InstanceVO extends BaseEntity {
private Long credentialId;
private String adminCredentialRef;
private String regionId;
+ private String regionName;
private int topicCount;
private int consumerGroupCount;
@Builder.Default
diff --git a/server/src/main/resources/regions.properties
b/server/src/main/resources/regions.properties
new file mode 100644
index 000000000..1e211d9ce
--- /dev/null
+++ b/server/src/main/resources/regions.properties
@@ -0,0 +1,61 @@
+# 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.
+# Region display names for the instance list.
+# Source of truth: terrances project/regions.md (RocketMQ 5.0 region baseline,
36 regions).
+# Loaded at startup by RegionNames; unknown region ids fall back to the raw id.
+cn-hangzhou=\u534e\u4e1c1\uff08\u676d\u5dde\uff09
+cn-shanghai=\u534e\u4e1c2\uff08\u4e0a\u6d77\uff09
+cn-beijing=\u534e\u53172\uff08\u5317\u4eac\uff09
+cn-shenzhen=\u534e\u53571\uff08\u6df1\u5733\uff09
+cn-zhangjiakou=\u534e\u53173\uff08\u5f20\u5bb6\u53e3\uff09
+cn-qingdao=\u534e\u53171\uff08\u9752\u5c9b\uff09
+cn-huhehaote=\u534e\u53175\uff08\u547c\u548c\u6d69\u7279\uff09
+cn-wulanchabu=\u534e\u53176\uff08\u4e4c\u5170\u5bdf\u5e03\uff09
+cn-chengdu=\u897f\u53571\uff08\u6210\u90fd\uff09
+cn-guangzhou=\u534e\u53573\uff08\u5e7f\u5dde\uff09
+cn-fuzhou=\u534e\u4e1c\uff08\u798f\u5dde\uff09
+cn-heyuan=\u534e\u53572\uff08\u6cb3\u6e90\uff09
+cn-zhengzhou-jva=\u90d1\u5dde\uff08\u8054\u901a\u5408\u8425\uff09
+cn-hongkong=\u4e2d\u56fd\u9999\u6e2f
+ap-southeast-1=\u65b0\u52a0\u5761
+ap-southeast-3=\u9a6c\u6765\u897f\u4e9a\uff08\u5409\u9686\u5761\uff09
+ap-southeast-5=\u5370\u5ea6\u5c3c\u897f\u4e9a\uff08\u96c5\u52a0\u8fbe\uff09
+ap-southeast-6=\u83f2\u5f8b\u5bbe\uff08\u9a6c\u5c3c\u62c9\uff09
+ap-southeast-7=\u6cf0\u56fd\uff08\u66fc\u8c37\uff09
+ap-northeast-1=\u65e5\u672c\uff08\u4e1c\u4eac\uff09
+ap-northeast-2=\u97e9\u56fd\uff08\u9996\u5c14\uff09
+us-west-1=\u7f8e\u56fd\uff08\u7845\u8c37\uff09
+us-east-1=\u7f8e\u56fd\uff08\u5f17\u5409\u5c3c\u4e9a\uff09
+us-southeast-1=\u7f8e\u56fd\uff08\u4e9a\u7279\u5170\u5927\uff09
+na-south-1=\u58a8\u897f\u54e5
+eu-west-1=\u82f1\u56fd\uff08\u4f26\u6566\uff09
+eu-central-1=\u5fb7\u56fd\uff08\u6cd5\u5170\u514b\u798f\uff09
+me-east-1=\u963f\u8054\u914b\uff08\u8fea\u62dc\uff09
+me-central-1=\u6c99\u7279\uff08\u5229\u96c5\u5f97\uff09
+cn-shanghai-finance-1=\u4e0a\u6d77\u91d1\u878d\u4e91
+cn-shenzhen-finance-1=\u6df1\u5733\u91d1\u878d\u4e91
+cn-beijing-finance-1=\u5317\u4eac\u91d1\u878d\u4e91
+cn-hangzhou-finance=\u676d\u5dde\u91d1\u878d\u4e91
+cn-north-2-gov-1=\u653f\u52a1\u4e91
+cn-hangzhou-pre=\u676d\u5dde\u9884\u53d1
+cn-shanghai-cloudspe=\u4e0a\u6d77\u4e91\u9884\u53d1\u6f14\u7ec3\u73af\u5883
+ap-southeast-8=\u9a6c\u6765\u897f\u4e9a\uff08\u67d4\u4f5b\u5dde\uff09
+cn-qingdao-acdr-ut-1=\u9752\u5c9b\u6d77\u5c14\u4e13\u5c5e\u533a\u57df
+cn-wulanchabu-gic-1=\u534e\u53176\uff08\u4e4c\u5170\u5bdf\u5e03\uff09\u901a\u7528\u884c\u4e1a\u4e91
+cn-heyuan-acdr-1=\u6cb3\u6e90\u4e13\u5c5e\u4e91\u6c7d\u8f66\u5408\u89c4
+cn-zhongwei=\u897f\u53172\uff08\u4e2d\u536b\uff09
+eu-west-2=\u6cd5\u56fd\uff08\u5df4\u9ece\uff09
+cn-wuhan-lr=\u534e\u4e2d1\uff08\u6b66\u6c49\uff09\u672c\u5730\u5730\u57df
+cn-nanjing=\u534e\u4e1c5\uff08\u5357\u4eac\uff09\u672c\u5730\u5730\u57df
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/common/util/RegionNamesTest.java
b/server/src/test/java/org/apache/rocketmq/studio/common/util/RegionNamesTest.java
new file mode 100644
index 000000000..a86d60adf
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/common/util/RegionNamesTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+
+package org.apache.rocketmq.studio.common.util;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RegionNamesTest {
+
+ private RegionNames regionNames;
+
+ @BeforeEach
+ void setUp() {
+ regionNames = new RegionNames();
+ regionNames.load();
+ }
+
+ @Test
+ void resolveShouldReturnBundledDisplayNameTest() {
+
assertThat(regionNames.resolve("cn-hangzhou")).isEqualTo("\u534e\u4e1c1\uff08\u676d\u5dde\uff09");
+ assertThat(regionNames.resolve("cn-shanghai-cloudspe"))
+
.isEqualTo("\u4e0a\u6d77\u4e91\u9884\u53d1\u6f14\u7ec3\u73af\u5883");
+ assertThat(regionNames.resolve("cn-zhengzhou-jva"))
+ .isEqualTo("\u90d1\u5dde\uff08\u8054\u901a\u5408\u8425\uff09");
+ assertThat(regionNames.resolve("ap-southeast-8"))
+
.isEqualTo("\u9a6c\u6765\u897f\u4e9a\uff08\u67d4\u4f5b\u5dde\uff09");
+ assertThat(regionNames.resolve("cn-qingdao-acdr-ut-1"))
+ .isEqualTo("\u9752\u5c9b\u6d77\u5c14\u4e13\u5c5e\u533a\u57df");
+ assertThat(regionNames.resolve("cn-wulanchabu-gic-1"))
+
.isEqualTo("\u534e\u53176\uff08\u4e4c\u5170\u5bdf\u5e03\uff09\u901a\u7528\u884c\u4e1a\u4e91");
+ }
+
+ @Test
+ void resolveShouldFallBackToRawIdForUnknownRegionsTest() {
+
assertThat(regionNames.resolve("mars-north-1")).isEqualTo("mars-north-1");
+ assertThat(regionNames.resolve(" cn-hangzhou
")).isEqualTo("\u534e\u4e1c1\uff08\u676d\u5dde\uff09");
+ }
+
+ @Test
+ void resolveShouldPassThroughBlankValuesTest() {
+ assertThat(regionNames.resolve(null)).isNull();
+ assertThat(regionNames.resolve("")).isEmpty();
+ }
+}
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 0d5474f30..7799e3034 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
@@ -24,6 +24,7 @@ import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.util.RegionNames;
import org.apache.rocketmq.studio.provider.CloudCatalogProvider;
import org.apache.rocketmq.studio.provider.CloudInstanceDetailVO;
import org.apache.rocketmq.studio.provider.CloudInstanceOptionVO;
@@ -79,6 +80,9 @@ class InstanceServiceTest {
@Mock
private SettingsRepository settingsRepository;
+ @Mock
+ private RegionNames regionNames;
+
@InjectMocks
private InstanceService instanceService;
@@ -97,14 +101,32 @@ class InstanceServiceTest {
verify(instanceRepository).findAll();
}
+ @Test
+ void listInstancesShouldResolveRegionDisplayNamesTest() {
+ InstanceVO instance = InstanceVO.builder()
+ .name("cloud-1")
+ .vendor(InstanceVendor.ALIYUN)
+ .regionId("cn-hangzhou")
+ .build();
+ instance.setId(1L);
+ when(instanceRepository.findAll()).thenReturn(List.of(instance));
+
when(providerRegistry.forVendor(InstanceVendor.ALIYUN)).thenReturn(instanceProvider);
+ when(regionNames.resolve("cn-hangzhou")).thenReturn("Hangzhou (CN)");
+
+ List<InstanceVO> result = instanceService.listInstances(null, null);
+
+ assertThat(result.get(0).getRegionName()).isEqualTo("Hangzhou (CN)");
+ }
+
@Test
void
listInstancesShouldSortApacheFirstThenCloudVendorsAlphabeticallyTest() {
List<InstanceVO> instances = List.of(
-
InstanceVO.builder().name("z-aliyun").vendor(InstanceVendor.ALIYUN).build(),
+
InstanceVO.builder().name("z-aliyun").vendor(InstanceVendor.ALIYUN).regionId("cn-hangzhou").build(),
InstanceVO.builder().name("b-apache").vendor(InstanceVendor.APACHE).build(),
-
InstanceVO.builder().name("a-tencent").vendor(InstanceVendor.TENCENT).build(),
+
InstanceVO.builder().name("a-tencent").vendor(InstanceVendor.TENCENT).regionId("ap-chengdu").build(),
InstanceVO.builder().name("a-apache").build(),
-
InstanceVO.builder().name("a-aliyun").vendor(InstanceVendor.ALIYUN).build()
+
InstanceVO.builder().name("c-aliyun").vendor(InstanceVendor.ALIYUN).regionId("cn-beijing").build(),
+
InstanceVO.builder().name("a-aliyun").vendor(InstanceVendor.ALIYUN).regionId("cn-beijing").build()
);
when(instanceRepository.findAll()).thenReturn(instances);
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
@@ -112,7 +134,8 @@ class InstanceServiceTest {
List<InstanceVO> result = instanceService.listInstances(null, null);
assertThat(result).extracting(InstanceVO::getName)
- .containsExactly("a-apache", "b-apache", "a-aliyun",
"z-aliyun", "a-tencent");
+ .containsExactly("a-apache", "b-apache",
+ "a-aliyun", "c-aliyun", "z-aliyun", "a-tencent");
}
@Test
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index 853b923d9..bd9f1515a 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -39,6 +39,7 @@ export interface Instance {
credentialId?: number;
adminCredentialRef?: string;
regionId?: string;
+ regionName?: string;
topicCount: number;
consumerGroupCount: number;
resourceCountsAvailable?: boolean;
diff --git a/web/src/hooks/useInstanceFilter.ts
b/web/src/hooks/useInstanceFilter.ts
index b403ca184..6c70aded9 100644
--- a/web/src/hooks/useInstanceFilter.ts
+++ b/web/src/hooks/useInstanceFilter.ts
@@ -37,6 +37,7 @@ export function useInstanceFilter() {
const section = scopedMatch?.[2] ?? staticMatch?.[1] ?? 'topic';
const [instances, setInstances] = useState<Instance[]>([]);
+ const [instancesLoading, setInstancesLoading] = useState(true);
// Keep the latest selected instance id in a ref so the instance *list* is
only
// fetched when needed (section / navigation changes) and not re-fetched
every
@@ -54,7 +55,9 @@ export function useInstanceFilter() {
if (cancelled) return;
setInstances(nextInstances);
const selectedInstanceId = routeInstanceIdRef.current;
- const isKnownInstance = nextInstances.some((instance) => instance.name
=== selectedInstanceId);
+ const isKnownInstance = nextInstances.some(
+ (instance) => instance.name === selectedInstanceId,
+ );
if (nextInstances.length > 0 && !isKnownInstance) {
navigate(`/instance/${encodeURIComponent(nextInstances[0].name)}/${section}`, {
replace: true,
@@ -63,6 +66,9 @@ export function useInstanceFilter() {
})
.catch(() => {
// 实例列表加载失败时不做实例过滤,保持页面数据可用
+ })
+ .finally(() => {
+ if (!cancelled) setInstancesLoading(false);
});
return () => {
cancelled = true;
@@ -86,6 +92,7 @@ export function useInstanceFilter() {
return {
instances,
+ instancesLoading,
selectedInstanceId,
selectedInstance,
selectInstance,
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index d28399a1d..0982121e7 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -690,13 +690,28 @@ describe('Consumer page', () => {
);
});
+ it('shows a spinner while the instance list is still resolving', async () =>
{
+ let resolveInstances!: (value: never[]) => void;
+ instanceServiceMocks.listInstances.mockReturnValue(
+ new Promise((resolve) => {
+ resolveInstances = resolve;
+ }),
+ );
+ renderWithProviders(<ConsumerPage />);
+
+ expect(document.querySelector('.ant-spin-spinning')).not.toBeNull();
+
+ resolveInstances([]);
+ await waitFor(() =>
expect(document.querySelector('.ant-spin-spinning')).toBeNull());
+ });
+
it('disables Consumer Group writes until an instance is available', async ()
=> {
instanceServiceMocks.listInstances.mockResolvedValue([]);
renderWithProviders(<ConsumerPage />);
expect(await screen.findByText('选择实例')).toBeInTheDocument();
expect(consumerService.listConsumerGroupPage).not.toHaveBeenCalled();
- expect(document.querySelector('.ant-spin-spinning')).toBeNull();
+ await waitFor(() =>
expect(document.querySelector('.ant-spin-spinning')).toBeNull());
expect(screen.getByRole('button', { name: /导入/ })).toBeDisabled();
expect(screen.getByRole('button', { name: '创建 Group' })).toBeDisabled();
});
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 51ac7a8ad..e8a7516cd 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -171,9 +171,13 @@ describe('InstancePage', () => {
await screen.findByText('unavailable-instance');
const topicHeader = screen.getByRole('columnheader', { name: 'Topic' });
const rowNames = () =>
- Array.from(container.querySelectorAll('tbody tr')).map(
- (row) => row.querySelector('td')?.textContent,
- );
+ Array.from(container.querySelectorAll('tbody tr'))
+ .map((row) =>
+ ['zero-instance', 'many-instance',
'unavailable-instance'].find((name) =>
+ row.textContent?.includes(name),
+ ),
+ )
+ .filter((name): name is string => Boolean(name));
fireEvent.click(topicHeader);
await waitFor(() => {
@@ -744,10 +748,29 @@ describe('InstancePage', () => {
renderPage();
const name = await screen.findByText('instance-without-remark');
- expect(within(name.closest('tr')!).getByText('-')).toBeInTheDocument();
+
expect(within(name.closest('tr')!).getAllByText('-').length).toBeGreaterThan(0);
await user.click(screen.getByRole('columnheader', { name: /备注/ }));
expect(screen.getByText('instance-without-remark')).toBeInTheDocument();
expect(screen.getByText('instance-with-remark')).toBeInTheDocument();
});
+
+ it('renders the region column with regionId for cloud instances and a dash
for open-source ones', async () => {
+ vi.mocked(instanceService.listInstances).mockResolvedValue([
+ {
+ ...instance(12, 'rmq-cloud-1', 'CLOUD', ''),
+ vendor: 'ALIYUN',
+ regionId: 'cn-hangzhou',
+ },
+ instance(13, 'open-source-1', 'DIRECT', ''),
+ ]);
+ renderPage();
+
+ expect(await screen.findByRole('columnheader', { name: '地域'
})).toBeInTheDocument();
+ const cloudRow = (await screen.findByText('rmq-cloud-1')).closest('tr')!;
+ expect(within(cloudRow).getByText('cn-hangzhou')).toBeInTheDocument();
+ const apacheRow = screen.getByText('open-source-1').closest('tr')!;
+ expect(within(apacheRow).getAllByText('-').length).toBeGreaterThan(0);
+
expect(within(apacheRow).queryByText('cn-hangzhou')).not.toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 8e27f3335..9338a5ba9 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -170,6 +170,7 @@ const ConsumerPageContent = ({
selectedInstance,
selectInstance,
instanceOptions,
+ instancesLoading,
}: ConsumerPageContentProps) => {
const { t } = useLang();
const isCloudInstance =
@@ -177,7 +178,7 @@ const ConsumerPageContent = ({
const hasSelectedInstance = Boolean(selectedInstanceId);
const [groups, setGroups] = useState<ConsumerGroup[]>([]);
const [totalGroups, setTotalGroups] = useState(0);
- const [loading, setLoading] = useState(hasSelectedInstance);
+ const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [resetSubmitting, setResetSubmitting] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
@@ -223,7 +224,15 @@ const ConsumerPageContent = ({
useEffect(() => {
if (!selectedInstanceId) {
groupRequestIdRef.current += 1;
- return;
+ const resetTimer = window.setTimeout(() => {
+ setGroups([]);
+ setTotalGroups(0);
+ setSelectedRowKeys([]);
+ setLoading(instancesLoading);
+ }, 0);
+ return () => {
+ window.clearTimeout(resetTimer);
+ };
}
const requestId = ++groupRequestIdRef.current;
const timer = window.setTimeout(() => {
@@ -250,7 +259,7 @@ const ConsumerPageContent = ({
return () => {
window.clearTimeout(timer);
};
- }, [t, selectedInstanceId, search, page, pageSize]);
+ }, [t, selectedInstanceId, search, page, pageSize, instancesLoading]);
const loadSubscriptions = useCallback(
async (groupName: string, force = false) => {
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 0d74e93ff..d985c0eaf 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -411,14 +411,28 @@ const InstancePage = () => {
}
};
- const sortedInstances = [...instances].sort((a, b) =>
a.name.localeCompare(b.name));
-
const columns: ColumnsType<Instance> = [
+ {
+ title: '地域',
+ dataIndex: 'regionId',
+ key: 'regionId',
+ width: 130,
+ ellipsis: true,
+ onHeaderCell: () => ({ style: { textAlign: 'left' } }),
+ sorter: (a, b) => (a.regionId ?? '').localeCompare(b.regionId ?? ''),
+ render: (regionId: string | undefined, record: Instance) => (
+ <Text type="secondary" style={{ fontSize: 14 }}>
+ {!record.vendor || record.vendor === 'APACHE' || !regionId
+ ? '-'
+ : record.regionName || regionId}
+ </Text>
+ ),
+ },
{
title: '实例 ID',
dataIndex: 'name',
key: 'name',
- width: 180,
+ ellipsis: true,
onHeaderCell: () => ({ style: { textAlign: 'left' } }),
sorter: (a, b) => a.name.localeCompare(b.name),
render: (text: string) => (
@@ -431,7 +445,6 @@ const InstancePage = () => {
title: '备注',
dataIndex: 'remark',
key: 'remark',
- width: 240,
ellipsis: { showTitle: false },
onHeaderCell: () => ({ style: { textAlign: 'left' } }),
sorter: (a, b) => (a.remark ?? '').localeCompare(b.remark ?? ''),
@@ -452,7 +465,7 @@ const InstancePage = () => {
title: '厂商',
dataIndex: 'vendor',
key: 'vendor',
- width: 140,
+ width: 100,
align: 'center' as const,
render: (value?: string) => {
const option = VENDOR_OPTIONS.find((item) => item.key === (value ||
'APACHE'));
@@ -471,7 +484,7 @@ const InstancePage = () => {
title: '类型',
dataIndex: 'type',
key: 'type',
- width: 130,
+ width: 110,
align: 'center' as const,
sorter: (a, b) => a.type.localeCompare(b.type),
render: (type: string) => {
@@ -483,7 +496,7 @@ const InstancePage = () => {
title: 'Topic',
dataIndex: 'topicCount',
key: 'topicCount',
- width: 80,
+ width: 70,
align: 'center' as const,
sorter: (a, b, sortOrder) => compareResourceCounts(a, b, 'topicCount',
sortOrder),
render: (count: number, record: Instance) =>
@@ -493,7 +506,7 @@ const InstancePage = () => {
title: 'Group',
dataIndex: 'consumerGroupCount',
key: 'consumerGroupCount',
- width: 80,
+ width: 70,
align: 'center' as const,
sorter: (a, b, sortOrder) => compareResourceCounts(a, b,
'consumerGroupCount', sortOrder),
render: (count: number, record: Instance) =>
@@ -503,7 +516,7 @@ const InstancePage = () => {
title: '创建时间',
dataIndex: 'gmtCreate',
key: 'gmtCreate',
- width: 170,
+ width: 150,
sorter: (a, b) => a.gmtCreate.localeCompare(b.gmtCreate),
render: (d: string) => (
<Text type="secondary" style={{ fontSize: 14 }}>
@@ -515,7 +528,7 @@ const InstancePage = () => {
title: '修改时间',
dataIndex: 'gmtModified',
key: 'gmtModified',
- width: 170,
+ width: 150,
sorter: (a, b) => a.gmtModified.localeCompare(b.gmtModified),
render: (d: string) => (
<Text type="secondary" style={{ fontSize: 14 }}>
@@ -526,7 +539,7 @@ const InstancePage = () => {
{
title: '操作',
key: 'actions',
- width: 160,
+ width: 150,
render: (_: unknown, record: Instance) => (
<Flex gap={6} onClick={(e) => e.stopPropagation()}>
<Button
@@ -624,11 +637,12 @@ const InstancePage = () => {
<Table
className="instance-table"
columns={columns}
- dataSource={sortedInstances}
+ dataSource={instances}
loading={loading}
rowKey="id"
pagination={false}
size="small"
+ tableLayout="fixed"
onRow={(record) => ({
style: { cursor: 'pointer' },
onClick: () =>
navigate(`/instance/${encodeURIComponent(record.name)}/topic`),
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index da0cfb4ac..38db6ed91 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -273,8 +273,13 @@ const formatDateTime = (iso?: string): string => {
// ═══════════════════════════════════════════════════════════════════
const TopicPage = () => {
const { t } = useLang();
- const { selectedInstanceId, selectedInstance, selectInstance,
instanceOptions } =
- useInstanceFilter();
+ const {
+ selectedInstanceId,
+ selectedInstance,
+ selectInstance,
+ instanceOptions,
+ instancesLoading,
+ } = useInstanceFilter();
const isCloudInstance =
selectedInstance?.vendor === 'ALIYUN' || selectedInstance?.vendor ===
'TENCENT';
const hasSelectedInstance = Boolean(selectedInstanceId);
@@ -282,7 +287,7 @@ const TopicPage = () => {
// ─── State ─────────────────────────────────────────────────────
const [topics, setTopics] = useState<Topic[]>([]);
const [totalTopics, setTotalTopics] = useState(0);
- const [loading, setLoading] = useState(false);
+ const [loading, setLoading] = useState(true);
const [routesByTopic, setRoutesByTopic] = useState<Record<string,
BrokerRoute[]>>({});
const [consumersByTopic, setConsumersByTopic] = useState<Record<string,
TopicConsumerPage>>({});
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
@@ -329,7 +334,7 @@ const TopicPage = () => {
setTopics([]);
setTotalTopics(0);
setSelectedRowKeys([]);
- setLoading(false);
+ setLoading(instancesLoading);
}, 0);
return () => {
window.clearTimeout(resetTimer);
@@ -363,7 +368,7 @@ const TopicPage = () => {
return () => {
window.clearTimeout(timer);
};
- }, [selectedInstanceId, typeFilter, searchText, tablePage, tablePageSize]);
+ }, [selectedInstanceId, typeFilter, searchText, tablePage, tablePageSize,
instancesLoading]);
// ─── Filtered data ─────────────────────────────────────────────
const filteredTopics = useMemo(
diff --git a/web/src/services/instanceService.test.ts
b/web/src/services/instanceService.test.ts
index 12e7d2b4a..c7da95009 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -15,9 +15,12 @@
* limitations under the License.
*/
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
-vi.mock('./dataMode', () => ({ isMockMode: () => true }));
+const dataModeMock = vi.hoisted(() => ({ isMockMode: vi.fn(() => true) }));
+vi.mock('./dataMode', () => dataModeMock);
+const instanceApiMock = vi.hoisted(() => ({ listInstances: vi.fn() }));
+vi.mock('../api/instance', () => instanceApiMock);
vi.mock('../config', () => ({
API_BASE_URL: '/api',
}));
@@ -29,6 +32,7 @@ import {
listInstances,
updateInstance,
} from './instanceService';
+import type { Instance } from '../api/instance';
describe('instanceService mock instances', () => {
it('returns defensive copies from list reads', async () => {
@@ -118,3 +122,47 @@ describe('instanceService mock instances', () => {
);
});
});
+
+describe('instanceService list request dedupe', () => {
+ beforeEach(() => {
+ dataModeMock.isMockMode.mockReturnValue(true);
+ instanceApiMock.listInstances.mockReset();
+ });
+
+ it('shares one inflight list request between concurrent callers', async ()
=> {
+ dataModeMock.isMockMode.mockReturnValue(false);
+ const fixture: Instance[] = [
+ {
+ id: 1,
+ name: 'shared-instance',
+ remark: null,
+ type: 'PROXY_CLUSTER',
+ endpoint: '10.0.0.1:8080',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-01-01T00:00:00Z',
+ gmtModified: '2026-01-01T00:00:00Z',
+ },
+ ];
+ let resolveList!: (value: Instance[]) => void;
+ instanceApiMock.listInstances.mockImplementation(
+ () =>
+ new Promise<Instance[]>((resolve) => {
+ resolveList = resolve;
+ }),
+ );
+
+ const first = listInstances({});
+ const second = listInstances({ search: ' ' });
+ resolveList(fixture);
+
+ const [a, b] = await Promise.all([first, second]);
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(1);
+ expect(a).toEqual(b);
+ expect(a).not.toBe(b);
+
+ instanceApiMock.listInstances.mockResolvedValue(fixture);
+ await listInstances({});
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index d50676b21..4d9e8e12e 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -38,7 +38,20 @@ const CLOUD_CAPABILITIES:
InstanceCapabilities['capabilities'] = [
'ACL_MANAGEMENT',
];
-export async function listInstances(query: InstanceQuery = {}):
Promise<Instance[]> {
+const inflightListRequests = new Map<string, Promise<Instance[]>>();
+
+export function listInstances(query: InstanceQuery = {}): Promise<Instance[]> {
+ const key = JSON.stringify([query.type ?? null, query.search?.trim() ||
null]);
+ const inflight = inflightListRequests.get(key);
+ if (inflight) {
+ return inflight.then((items) => items.map(copyInstance));
+ }
+ const request = fetchInstances(query).finally(() =>
inflightListRequests.delete(key));
+ inflightListRequests.set(key, request);
+ return request.then((items) => items.map(copyInstance));
+}
+
+async function fetchInstances(query: InstanceQuery): Promise<Instance[]> {
if (isMockMode()) {
const search = query.search?.trim().toLowerCase();
return mockInstances