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 6f7559c8f perf(instance): make resource counts opt-in and stop pages
waiting on the list (#6691)
6f7559c8f is described below
commit 6f7559c8fffb6e756b91661e7a68c0bccc16b20e
Author: lizhimins <[email protected]>
AuthorDate: Thu Oct 15 10:23:37 2026 +0800
perf(instance): make resource counts opt-in and stop pages waiting on the
list (#6691)
GET /api/instances resolved topic/group counts for every caller, which
means two
remote OpenAPI calls per cloud instance. With 11 cloud instances configured
the
endpoint took 1.5-3.0s (measured against a deployed studio), and because
instance-scoped pages derive their selected instance from that list, they
serialized their own data request behind it: the topic page rendered its
first
row after 3121ms and the consumer group page after 1957ms, while
/api/topics/page and /api/groups/page themselves answered in 17-27ms.
Counts now require ?withCounts=true and come back marked unavailable rather
than
as a plausible zero when they were not requested; the instance list page is
the
only place that shows them, so it is the only caller that asks. The instance
selector trusts the instance named in the route before the list lands, so
pages
fetch their own data immediately (measured 544ms / 370ms after the change,
one
list request per page view instead of two), and an unknown route id still
falls
back to the first instance and rewrites the route as before. The spinner
for the
no-instance state moves into its own effect so the load effect no longer
re-runs
when the list resolves.
---
docs/api-spec.md | 8 ++-
.../studio/instance/InstanceController.java | 5 +-
.../rocketmq/studio/instance/InstanceService.java | 17 +++++-
.../studio/instance/InstanceControllerTest.java | 28 ++++++++--
.../studio/instance/InstanceServiceTest.java | 50 +++++++++++------
web/src/api/instance.ts | 6 ++
web/src/hooks/useInstanceFilter.test.tsx | 47 ++++++++++++++++
web/src/hooks/useInstanceFilter.ts | 15 ++++-
.../pages/instance/__tests__/ConsumerPage.test.tsx | 21 +++++++
.../pages/instance/__tests__/InstancePage.test.tsx | 64 +++++++++++++++++-----
web/src/pages/instance/consumer.tsx | 14 ++++-
web/src/pages/instance/index.tsx | 2 +-
web/src/services/instanceService.ts | 9 ++-
13 files changed, 235 insertions(+), 51 deletions(-)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index bbbd2261b..5dd67f6cf 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -321,7 +321,7 @@ GET /api/dashboard
### 3.1 获取实例列表
```
-GET /api/instances?type={type}&search={keyword}
+GET /api/instances?type={type}&search={keyword}&withCounts={withCounts}
```
**Query Parameters:**
@@ -330,6 +330,7 @@ GET /api/instances?type={type}&search={keyword}
|------|------|------|------|
| `type` | `string` | 否 | 按类型过滤: `CLOUD` / `PROXY_LOCAL` / `PROXY_CLUSTER` /
`DIRECT` |
| `search` | `string` | 否 | 按名称或地址搜索 |
+| `withCounts` | `boolean` | 否 | 默认 `false`。为 `true` 时逐实例解析 Topic /
消费组数量;云厂商走远程 OpenAPI(每实例两次调用),十几实例量级耗时秒级,只需要实例名的调用方(如实例选择器)不要开启 |
**Response `data`:** `Instance[]`
@@ -340,8 +341,9 @@ GET /api/instances?type={type}&search={keyword}
| `remark` | `string` | 备注 |
| `type` | `string` | 接入类型: `CLOUD` / `PROXY_LOCAL` / `PROXY_CLUSTER` /
`DIRECT` |
| `endpoint` | `string` | 接入地址 |
-| `topicCount` | `number` | Topic 数量 |
-| `consumerGroupCount` | `number` | 消费组数量 |
+| `topicCount` | `number` | Topic 数量;未请求 `withCounts` 或解析失败时为 `0` |
+| `consumerGroupCount` | `number` | 消费组数量;未请求 `withCounts` 或解析失败时为 `0` |
+| `resourceCountsAvailable` | `boolean` | 上面两个计数是否可信;`false` 表示未请求或解析失败,此时不应把
`0` 当成真实数量 |
| `gmtCreate` | `string` | 创建时间 |
| `gmtModified` | `string` | 更新时间 |
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
index 4338dcd7d..2958febe3 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
@@ -43,8 +43,9 @@ public class InstanceController {
@GetMapping
public Result<List<InstanceVO>> listInstances(
@RequestParam(required = false) InstanceType type,
- @RequestParam(required = false) String search) {
- return Result.ok(instanceService.listInstances(type, search));
+ @RequestParam(required = false) String search,
+ @RequestParam(required = false, defaultValue = "false") boolean
withCounts) {
+ return Result.ok(instanceService.listInstances(type, search,
withCounts));
}
@GetMapping("/{instanceId}/capabilities")
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 713a07ebd..462e188d6 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
@@ -103,8 +103,15 @@ public class InstanceService {
countRunner.close();
}
- public List<InstanceVO> listInstances(InstanceType type, String search) {
- log.debug("Listing instances, type={}, search={}", type, search);
+ /**
+ * Lists instances, resolving resource counts only when {@code withCounts}
is set. Cloud vendors
+ * answer counts through remote OpenAPIs — two calls per instance — so a
list of a dozen
+ * instances costs seconds, which every caller that only needs names (the
instance selector on
+ * each instance-scoped page) must not pay. Without counts the rows are
marked unavailable rather
+ * than left at the VO default, so a zero can never be mistaken for a
measured count.
+ */
+ public List<InstanceVO> listInstances(InstanceType type, String search,
boolean withCounts) {
+ log.debug("Listing instances, type={}, search={}, withCounts={}",
type, search, withCounts);
String normalizedSearch = search == null || search.isBlank() ? null :
search.trim();
List<InstanceVO> instances;
@@ -117,7 +124,11 @@ public class InstanceService {
} else {
instances = instanceRepository.findAll();
}
- fillCountsInParallel(instances);
+ if (withCounts) {
+ fillCountsInParallel(instances);
+ } else {
+ instances.forEach(InstanceService::clearCounts);
+ }
instances.forEach(instance ->
instance.setRegionName(regionNames.resolve(instance.getRegionId())));
List<InstanceVO> sorted = new ArrayList<>(instances);
sorted.sort(Comparator
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceControllerTest.java
index 2b71f0558..6d53176a2 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceControllerTest.java
@@ -73,7 +73,7 @@ class InstanceControllerTest extends WebMvcAuthTestSupport {
void listInstancesShouldReturnAllInstances() throws Exception {
InstanceVO inst = buildInstance(1L, "production-proxy",
InstanceType.PROXY_CLUSTER, "10.0.1.1:8080");
- when(instanceService.listInstances(isNull(),
isNull())).thenReturn(List.of(inst));
+ when(instanceService.listInstances(isNull(), isNull(),
eq(false))).thenReturn(List.of(inst));
mockMvc.perform(get("/api/instances"))
.andExpect(status().isOk())
@@ -83,13 +83,31 @@ class InstanceControllerTest extends WebMvcAuthTestSupport {
.andExpect(jsonPath("$.data[0].name").value("production-proxy"))
.andExpect(jsonPath("$.data[0].type").value("PROXY_CLUSTER"))
.andExpect(jsonPath("$.data[0].endpoint").value("10.0.1.1:8080"));
+
+ // Counts cost a remote call per cloud instance, so the default
listing must not ask for them.
+ verify(instanceService).listInstances(isNull(), isNull(), eq(false));
+ }
+
+ @Test
+ void listInstancesShouldPassWithCountsThrough() throws Exception {
+ InstanceVO inst = buildInstance(1L, "production-proxy",
InstanceType.PROXY_CLUSTER, "10.0.1.1:8080");
+
+ when(instanceService.listInstances(isNull(), isNull(),
eq(true))).thenReturn(List.of(inst));
+
+ mockMvc.perform(get("/api/instances")
+ .param("withCounts", "true"))
+ .andExpect(status().isOk())
+
.andExpect(jsonPath("$.data[0].name").value("production-proxy"));
+
+ verify(instanceService).listInstances(isNull(), isNull(), eq(true));
}
@Test
void listInstancesShouldFilterByType() throws Exception {
InstanceVO inst = buildInstance(1L, "proxy-1", InstanceType.CLOUD,
"10.0.1.1:8080");
- when(instanceService.listInstances(eq(InstanceType.CLOUD),
isNull())).thenReturn(List.of(inst));
+ when(instanceService.listInstances(eq(InstanceType.CLOUD), isNull(),
eq(false)))
+ .thenReturn(List.of(inst));
mockMvc.perform(get("/api/instances")
.param("type", "CLOUD"))
@@ -97,21 +115,21 @@ class InstanceControllerTest extends WebMvcAuthTestSupport
{
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data[0].type").value("CLOUD"));
- verify(instanceService).listInstances(eq(InstanceType.CLOUD),
isNull());
+ verify(instanceService).listInstances(eq(InstanceType.CLOUD),
isNull(), eq(false));
}
@Test
void listInstancesShouldFilterBySearch() throws Exception {
InstanceVO inst = buildInstance(1L, "production",
InstanceType.PROXY_CLUSTER, "10.0.1.1:8080");
- when(instanceService.listInstances(isNull(),
eq("prod"))).thenReturn(List.of(inst));
+ when(instanceService.listInstances(isNull(), eq("prod"),
eq(false))).thenReturn(List.of(inst));
mockMvc.perform(get("/api/instances")
.param("search", "prod"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].name").value("production"));
- verify(instanceService).listInstances(isNull(), eq("prod"));
+ verify(instanceService).listInstances(isNull(), eq("prod"), eq(false));
}
@Test
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 31cd7eaa9..efbddcf2c 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
@@ -155,7 +155,7 @@ class InstanceServiceTest {
when(instanceRepository.findAll()).thenReturn(instances);
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
- List<InstanceVO> result = instanceService.listInstances(null, null);
+ List<InstanceVO> result = instanceService.listInstances(null, null,
true);
assertThat(result).hasSize(2);
verify(instanceRepository).findAll();
@@ -173,7 +173,7 @@ class InstanceServiceTest {
when(providerRegistry.forVendor(InstanceVendor.ALIYUN)).thenReturn(instanceProvider);
when(regionNames.resolve("cn-hangzhou")).thenReturn("Hangzhou (CN)");
- List<InstanceVO> result = instanceService.listInstances(null, null);
+ List<InstanceVO> result = instanceService.listInstances(null, null,
true);
assertThat(result.get(0).getRegionName()).isEqualTo("Hangzhou (CN)");
}
@@ -191,7 +191,7 @@ class InstanceServiceTest {
when(instanceRepository.findAll()).thenReturn(instances);
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
- List<InstanceVO> result = instanceService.listInstances(null, null);
+ List<InstanceVO> result = instanceService.listInstances(null, null,
true);
assertThat(result).extracting(InstanceVO::getName)
.containsExactly("a-apache", "b-apache",
@@ -207,7 +207,7 @@ class InstanceServiceTest {
when(providerRegistry.forVendor(InstanceVendor.ALIYUN)).thenReturn(provider);
when(provider.countTopics("aliyun-fail")).thenThrow(new
IllegalStateException("access denied"));
- InstanceVO result = instanceService.listInstances(null, null).get(0);
+ InstanceVO result = instanceService.listInstances(null, null,
true).get(0);
assertThat(result.isResourceCountsAvailable()).isFalse();
}
@@ -222,13 +222,29 @@ class InstanceServiceTest {
when(provider.countTopics("aliyun-empty")).thenReturn(0);
when(provider.countGroups("aliyun-empty")).thenReturn(0);
- InstanceVO result = instanceService.listInstances(null, null).get(0);
+ InstanceVO result = instanceService.listInstances(null, null,
true).get(0);
assertThat(result.isResourceCountsAvailable()).isTrue();
assertThat(result.getTopicCount()).isZero();
assertThat(result.getConsumerGroupCount()).isZero();
}
+ @Test
+ void listInstancesWithoutCountsSkipsProviderLookupsTest() {
+ InstanceVO instance =
InstanceVO.builder().name("aliyun-1").vendor(InstanceVendor.ALIYUN).build();
+ instance.setId(3L);
+ instance.setTopicCount(7);
+ instance.setConsumerGroupCount(9);
+ when(instanceRepository.findAll()).thenReturn(List.of(instance));
+
+ InstanceVO result = instanceService.listInstances(null, null,
false).get(0);
+
+ assertThat(result.isResourceCountsAvailable()).isFalse();
+ assertThat(result.getTopicCount()).isZero();
+ assertThat(result.getConsumerGroupCount()).isZero();
+ verifyNoInteractions(providerRegistry);
+ }
+
@Test
void listInstancesShouldFilterByType() {
List<InstanceVO> instances = List.of(
@@ -237,7 +253,7 @@ class InstanceServiceTest {
when(instanceRepository.findByType(InstanceType.CLOUD)).thenReturn(instances);
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
- List<InstanceVO> result =
instanceService.listInstances(InstanceType.CLOUD, null);
+ List<InstanceVO> result =
instanceService.listInstances(InstanceType.CLOUD, null, true);
assertThat(result).hasSize(1);
verify(instanceRepository).findByType(InstanceType.CLOUD);
@@ -251,7 +267,7 @@ class InstanceServiceTest {
when(instanceRepository.search("prod")).thenReturn(instances);
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
- List<InstanceVO> result = instanceService.listInstances(null, "prod");
+ List<InstanceVO> result = instanceService.listInstances(null, "prod",
true);
assertThat(result).hasSize(1);
verify(instanceRepository).search("prod");
@@ -265,7 +281,7 @@ class InstanceServiceTest {
when(instanceRepository.search("prod")).thenReturn(instances);
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
- List<InstanceVO> result = instanceService.listInstances(null, " prod
");
+ List<InstanceVO> result = instanceService.listInstances(null, " prod
", true);
assertThat(result).hasSize(1);
verify(instanceRepository).search("prod");
@@ -279,7 +295,7 @@ class InstanceServiceTest {
when(instanceRepository.findByTypeAndSearch(InstanceType.CLOUD,
"prod")).thenReturn(instances);
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
- List<InstanceVO> result =
instanceService.listInstances(InstanceType.CLOUD, "prod");
+ List<InstanceVO> result =
instanceService.listInstances(InstanceType.CLOUD, "prod", true);
assertThat(result).hasSize(1);
verify(instanceRepository).findByTypeAndSearch(InstanceType.CLOUD,
"prod");
@@ -293,7 +309,7 @@ class InstanceServiceTest {
when(instanceRepository.findByTypeAndSearch(InstanceType.CLOUD,
"prod")).thenReturn(instances);
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
- List<InstanceVO> result =
instanceService.listInstances(InstanceType.CLOUD, " prod ");
+ List<InstanceVO> result =
instanceService.listInstances(InstanceType.CLOUD, " prod ", true);
assertThat(result).hasSize(1);
verify(instanceRepository).findByTypeAndSearch(InstanceType.CLOUD,
"prod");
@@ -305,7 +321,7 @@ class InstanceServiceTest {
when(instanceRepository.findAll()).thenReturn(instances);
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
- List<InstanceVO> result = instanceService.listInstances(null, " ");
+ List<InstanceVO> result = instanceService.listInstances(null, " ",
true);
assertThat(result).hasSize(1);
verify(instanceRepository).findAll();
@@ -326,7 +342,7 @@ class InstanceServiceTest {
when(aliyunProvider.countTopics("aliyun")).thenReturn(5);
when(aliyunProvider.countGroups("aliyun")).thenReturn(4);
- List<InstanceVO> result = instanceService.listInstances(null, null);
+ List<InstanceVO> result = instanceService.listInstances(null, null,
true);
assertThat(result.get(0).getTopicCount()).isEqualTo(3);
assertThat(result.get(0).getConsumerGroupCount()).isEqualTo(2);
@@ -343,7 +359,7 @@ class InstanceServiceTest {
when(instanceProvider.countTopics("broken"))
.thenThrow(new IllegalStateException("admin unavailable"));
- List<InstanceVO> result = instanceService.listInstances(null, null);
+ List<InstanceVO> result = instanceService.listInstances(null, null,
true);
assertThat(result.get(0).getTopicCount()).isZero();
assertThat(result.get(0).getConsumerGroupCount()).isZero();
@@ -361,7 +377,7 @@ class InstanceServiceTest {
when(instanceProvider.countTopics("stale-counts"))
.thenThrow(new IllegalStateException("provider unavailable"));
- InstanceVO result = instanceService.listInstances(null, null).get(0);
+ InstanceVO result = instanceService.listInstances(null, null,
true).get(0);
assertThat(result.isResourceCountsAvailable()).isFalse();
assertThat(result.getTopicCount()).isZero();
@@ -386,7 +402,7 @@ class InstanceServiceTest {
});
long start = System.nanoTime();
- List<InstanceVO> result = instanceService.listInstances(null, null);
+ List<InstanceVO> result = instanceService.listInstances(null, null,
true);
long elapsedMillis = (System.nanoTime() - start) / 1_000_000;
assertThat(elapsedMillis)
@@ -429,7 +445,7 @@ class InstanceServiceTest {
});
try {
- List<InstanceVO> result = instanceService.listInstances(null,
null);
+ List<InstanceVO> result = instanceService.listInstances(null,
null, true);
assertThat(topicCountStarted.await(1, TimeUnit.SECONDS)).isTrue();
assertThat(result).singleElement().satisfies(instance -> {
@@ -1196,7 +1212,7 @@ class InstanceServiceTest {
when(instanceProvider.countTopics("42")).thenReturn(3);
when(instanceProvider.countGroups("42")).thenReturn(2);
- List<InstanceVO> result = instanceService.listInstances(null, null);
+ List<InstanceVO> result = instanceService.listInstances(null, null,
true);
assertThat(result.get(0).getTopicCount()).isEqualTo(3);
assertThat(result.get(0).getConsumerGroupCount()).isEqualTo(2);
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index 9bf6f31db..6d17aefef 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -73,6 +73,11 @@ export interface UpdateInstanceRequest {
export interface InstanceQuery {
type?: Instance['type'];
search?: string;
+ /**
+ * Counts cost the backend a remote OpenAPI round trip per cloud instance,
so ask for them only
+ * where they are displayed. Without it the response reports
`resourceCountsAvailable: false`.
+ */
+ withCounts?: boolean;
}
/** Whether an instance can use Apache MQAdmin-backed runtime diagnostics. */
@@ -93,6 +98,7 @@ export async function listInstances(query: InstanceQuery =
{}) {
const params = {
...(query.type ? { type: query.type } : {}),
...(search ? { search } : {}),
+ ...(query.withCounts ? { withCounts: true } : {}),
};
const res = await client.get<{ data: Instance[] }>('/instances', { params });
return res.data.data;
diff --git a/web/src/hooks/useInstanceFilter.test.tsx
b/web/src/hooks/useInstanceFilter.test.tsx
index fa9d6438d..39077e5fe 100644
--- a/web/src/hooks/useInstanceFilter.test.tsx
+++ b/web/src/hooks/useInstanceFilter.test.tsx
@@ -67,6 +67,53 @@ describe('useInstanceFilter', () => {
});
});
+ it('selects the route instance while the list is still loading', async () =>
{
+ let release: (instances: unknown[]) => void = () => {};
+ // Once-scoped on purpose: this file never clears the shared mock, so a
persistent pending
+ // implementation would leak into the tests below and hang their retry.
+ instanceServiceMocks.listInstances.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ render(
+ <MemoryRouter initialEntries={['/instance/instance-a/topic']}>
+ <Routes>
+ <Route path="/instance/:instanceId/topic"
element={<InstanceRouteProbe />} />
+ </Routes>
+ </MemoryRouter>,
+ );
+
+ // Pages gate their own data request on selectedInstanceId, so the route
instance must win
+ // before the list lands instead of serializing the page behind it. The
selector also renders
+ // names only, so it must not ask for the (remotely resolved,
seconds-slow) resource counts.
+ await waitFor(() => {
+
expect(screen.getByText('/instance/instance-a/topic|instance-a')).toBeInTheDocument();
+ });
+ expect(instanceServiceMocks.listInstances).toHaveBeenCalledWith();
+
+ release([
+ {
+ id: 7,
+ name: 'instance-a',
+ remark: '',
+ type: 'DIRECT',
+ endpoint: '127.0.0.1:9876',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ gmtCreate: '2026-01-01T00:00:00Z',
+ gmtModified: '2026-01-01T00:00:00Z',
+ },
+ ]);
+
+ await waitFor(() => {
+
expect(screen.getByTestId('instances-failed')).toHaveTextContent('false');
+ });
+
expect(screen.getByText('/instance/instance-a/topic|instance-a')).toBeInTheDocument();
+ });
+
it('reports a failed instance list and recovers from a retry', async () => {
instanceServiceMocks.listInstances
.mockRejectedValueOnce(new Error('offline'))
diff --git a/web/src/hooks/useInstanceFilter.ts
b/web/src/hooks/useInstanceFilter.ts
index f6666f96a..0f4370290 100644
--- a/web/src/hooks/useInstanceFilter.ts
+++ b/web/src/hooks/useInstanceFilter.ts
@@ -36,8 +36,11 @@ function decodeRouteSegment(segment: string | undefined) {
* 实例维度页面的公共筛选逻辑:从 /instance/:instanceId/<section> 路由解析当前实例,
* 无实例参数时重定向到第一个实例。
*
- * 实例列表加载失败时没有可用的降级:服务端的实例维度接口都要求 instanceId,所以页面只能
- * 停在“无实例”状态。失败因此必须暴露给页面(instancesFailed),否则一次失败的请求与
+ * 路由上已带实例名时,页面不必等实例列表返回就能发自己的数据请求(见下方 selectedInstanceId);
+ * 只有「路由没带实例名」或「路由上的名字不在列表里」才需要列表来决定用哪个实例。
+ *
+ * 实例列表加载失败时没有可用的降级:服务端的实例维度接口都要求 instanceId,所以路由不带实例名的
+ * 页面只能停在“无实例”状态。失败因此必须暴露给页面(instancesFailed),否则一次失败的请求与
* “该实例下确实没有数据”在界面上无法区分,而页面会把后者当成空结果展示。
*/
export function useInstanceFilter() {
@@ -91,8 +94,14 @@ export function useInstanceFilter() {
};
}, [navigate, section, reloadToken]);
+ // The route already names the instance, so trust it before the list lands:
pages gate their own
+ // data request on selectedInstanceId, and making them wait would serialize
the (slow, cloud
+ // instances are resolved remotely) list request ahead of every
topic/group/message fetch. Once the
+ // list arrives an unknown route id still falls back to the first instance,
and the effect above
+ // rewrites the route to match.
const selectedInstanceId =
- routeInstanceId !== undefined && instances.some((instance) =>
instance.name === routeInstanceId)
+ routeInstanceId !== undefined &&
+ (instances.length === 0 || instances.some((instance) => instance.name ===
routeInstanceId))
? routeInstanceId
: instances[0]?.name;
const selectedInstance = instances.find((instance) => instance.name ===
selectedInstanceId);
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 9ee81317b..8530e29e9 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -312,6 +312,27 @@ describe('Consumer page', () => {
expect(screen.getByText(/^\d+ groups in total$/)).toBeInTheDocument();
});
+ it('loads the group page once when the instance list lands after it', async
() => {
+ let releaseInstances: (instances: unknown[]) => void = () => {};
+ instanceServiceMocks.listInstances.mockImplementationOnce(
+ () =>
+ new Promise<unknown[]>((resolve) => {
+ releaseInstances = resolve;
+ }),
+ );
+ renderWithProviders(<ConsumerPage />, '/instance/instance-1/consumer');
+
+ // The route already names the instance, so the page must not wait for the
instance list…
+ await waitFor(() =>
expect(consumerService.listConsumerGroupPage).toHaveBeenCalledTimes(1));
+
+ // …and the list landing afterwards must not fire a second, identical
request.
+ await act(async () => {
+ releaseInstances([]);
+ });
+ await screen.findByText('remote-cg');
+ expect(consumerService.listConsumerGroupPage).toHaveBeenCalledTimes(1);
+ });
+
it('loads consumer groups through the service layer', async () => {
renderWithProviders(<ConsumerPage />);
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 5be2b341f..bc1f3a4dc 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -134,22 +134,29 @@ describe('InstancePage', () => {
renderPage();
expect(await screen.findByText('production-proxy')).toBeInTheDocument();
- expect(instanceService.listInstances).toHaveBeenCalledWith({});
+ expect(instanceService.listInstances).toHaveBeenCalledWith({ withCounts:
true });
fireEvent.change(screen.getByPlaceholderText('搜索实例 ID 或地址'), {
target: { value: 'proxy-hz' },
});
await waitFor(
- () => expect(instanceService.listInstances).toHaveBeenCalledWith({
search: 'proxy-hz' }),
+ () =>
+ expect(instanceService.listInstances).toHaveBeenCalledWith({
+ search: 'proxy-hz',
+ withCounts: true,
+ }),
{ timeout: 1000 },
);
fireEvent.change(screen.getByPlaceholderText('搜索实例 ID 或地址'), {
target: { value: '' },
});
- await waitFor(() =>
expect(instanceService.listInstances).toHaveBeenLastCalledWith({}), {
- timeout: 1000,
- });
+ await waitFor(
+ () => expect(instanceService.listInstances).toHaveBeenLastCalledWith({
withCounts: true }),
+ {
+ timeout: 1000,
+ },
+ );
const typeSelect = screen.getByRole('combobox');
fireEvent.mouseDown(typeSelect.parentElement!);
@@ -158,7 +165,10 @@ describe('InstancePage', () => {
);
await waitFor(() =>
- expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' }),
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({
+ type: 'DIRECT',
+ withCounts: true,
+ }),
);
});
@@ -227,7 +237,11 @@ describe('InstancePage', () => {
const searchInput = screen.getByPlaceholderText('搜索实例 ID 或地址');
fireEvent.change(searchInput, { target: { value: 'old' } });
await waitFor(
- () => expect(instanceService.listInstances).toHaveBeenCalledWith({
search: 'old' }),
+ () =>
+ expect(instanceService.listInstances).toHaveBeenCalledWith({
+ search: 'old',
+ withCounts: true,
+ }),
{
timeout: 1000,
},
@@ -235,7 +249,11 @@ describe('InstancePage', () => {
fireEvent.change(searchInput, { target: { value: 'latest' } });
await waitFor(
- () => expect(instanceService.listInstances).toHaveBeenCalledWith({
search: 'latest' }),
+ () =>
+ expect(instanceService.listInstances).toHaveBeenCalledWith({
+ search: 'latest',
+ withCounts: true,
+ }),
{ timeout: 1000 },
);
@@ -283,7 +301,10 @@ describe('InstancePage', () => {
await screen.findByText('Direct 模式', { selector:
'.ant-select-item-option-content' }),
);
await waitFor(() =>
- expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' }),
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({
+ type: 'DIRECT',
+ withCounts: true,
+ }),
);
await user.click(screen.getByRole('button', { name: /添加实例/ }));
@@ -305,7 +326,10 @@ describe('InstancePage', () => {
endpoint: 'proxy-new:8080',
}),
);
- expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' });
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({
+ type: 'DIRECT',
+ withCounts: true,
+ });
});
it('creates an Apache instance with an explicit Proxy Local deployment
type', async () => {
@@ -507,13 +531,19 @@ describe('InstancePage', () => {
await screen.findByText('Direct 模式', { selector:
'.ant-select-item-option-content' }),
);
await waitFor(() =>
- expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' }),
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({
+ type: 'DIRECT',
+ withCounts: true,
+ }),
);
await act(async () => pendingDelete.resolve());
await waitFor(() =>
expect(instanceService.listInstances).toHaveBeenCalledTimes(3));
- expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' });
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({
+ type: 'DIRECT',
+ withCounts: true,
+ });
confirmSpy.mockRestore();
});
@@ -1059,7 +1089,10 @@ describe('InstancePage', () => {
await user.type(screen.getByPlaceholderText('搜索实例 ID 或地址'), 'visible');
await waitFor(() =>
- expect(instanceService.listInstances).toHaveBeenLastCalledWith({ search:
'visible' }),
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({
+ search: 'visible',
+ withCounts: true,
+ }),
);
await waitFor(() =>
expect(screen.queryByText('search-selected')).not.toBeInTheDocument());
expect(deleteButton).toBeDisabled();
@@ -1087,7 +1120,10 @@ describe('InstancePage', () => {
);
await waitFor(() =>
- expect(instanceService.listInstances).toHaveBeenLastCalledWith({ type:
'DIRECT' }),
+ expect(instanceService.listInstances).toHaveBeenLastCalledWith({
+ type: 'DIRECT',
+ withCounts: true,
+ }),
);
await waitFor(() =>
expect(screen.queryByText('proxy-selected')).not.toBeInTheDocument());
expect(deleteButton).toBeDisabled();
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index ae78fe23a..e73718e70 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -380,6 +380,17 @@ const ConsumerPageContent = ({
await loadConsumerGroupPage(page, pageSize);
}, [loadConsumerGroupPage, page, pageSize]);
+ // While no instance is selected the table has nothing to fetch, so its
spinner simply mirrors the
+ // instance list. Kept in its own effect: putting instancesLoading in the
deps of the load below
+ // would re-run it when the list lands and fetch the same page a second time.
+ useEffect(() => {
+ if (selectedInstanceId) return undefined;
+ const spinnerTimer = window.setTimeout(() => setLoading(instancesLoading),
0);
+ return () => {
+ window.clearTimeout(spinnerTimer);
+ };
+ }, [selectedInstanceId, instancesLoading]);
+
useEffect(() => {
if (!selectedInstanceId) {
groupRequestIdRef.current += 1;
@@ -387,7 +398,6 @@ const ConsumerPageContent = ({
setGroups([]);
setTotalGroups(0);
setSelectedRowKeys([]);
- setLoading(instancesLoading);
}, 0);
return () => {
window.clearTimeout(resetTimer);
@@ -401,7 +411,7 @@ const ConsumerPageContent = ({
return () => {
window.clearTimeout(timer);
};
- }, [selectedInstanceId, page, pageSize, instancesLoading, refreshKey,
loadConsumerGroupPage]);
+ }, [selectedInstanceId, page, pageSize, refreshKey, loadConsumerGroupPage]);
useEffect(() => {
if (!autoRefresh || !selectedInstanceId) {
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 6385185f3..93b962032 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -158,7 +158,7 @@ const InstancePage = () => {
setLoading(true);
try {
- const nextInstances = await listInstances(query);
+ const nextInstances = await listInstances({ ...query, withCounts: true
});
if (requestId === requestIdRef.current) {
setInstances(nextInstances);
const availableNames = new Set(nextInstances.map((instance) =>
instance.name));
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index f7a950532..366e9ef74 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -32,7 +32,14 @@ function invalidateInflightListRequests(): void {
export function listInstances(query: InstanceQuery = {}): Promise<Instance[]> {
const mockMode = isMockMode();
- const key = JSON.stringify([mockMode, query.type ?? null,
query.search?.trim() || null]);
+ // withCounts is part of the identity: a counts-free response must never
satisfy a caller that
+ // renders the counts (it would show zeros), nor the other way round.
+ const key = JSON.stringify([
+ mockMode,
+ query.type ?? null,
+ query.search?.trim() || null,
+ query.withCounts ?? false,
+ ]);
const inflight = inflightListRequests.get(key);
if (inflight) {
return inflight.then((items) => items.map(copyInstance));