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 2ebf97084 chore(studio): remove legacy group page, allow clearing LLM
key, dedupe broker config reads, dedupe query history (#2588)
2ebf97084 is described below
commit 2ebf97084f27759f3fcebcbeb5bc33091452015d
Author: xdz997 <[email protected]>
AuthorDate: Thu Aug 27 14:20:15 2026 +0800
chore(studio): remove legacy group page, allow clearing LLM key, dedupe
broker config reads, dedupe query history (#2588)
* chore: remove unreachable legacy group page
* fix: allow clearing the LLM API key
* fix: avoid duplicate cluster broker config reads
* fix: record message query history once per page set
---
.../studio/cluster/broker/ClusterService.java | 34 +-
.../studio/instance/message/MessageService.java | 13 +-
.../rocketmq/studio/ops/ai/LlmConfigDTO.java | 2 +
.../rocketmq/studio/ops/ai/LlmConfigService.java | 11 +-
.../apache/rocketmq/studio/ops/ai/LlmConfigVO.java | 2 +
.../studio/cluster/broker/ClusterServiceTest.java | 33 ++
.../instance/message/MessageServiceTest.java | 21 +
.../studio/ops/ai/LlmConfigServiceTest.java | 19 +
web/src/App.tsx | 6 +-
web/src/api/llm.ts | 1 +
web/src/i18n/translations.ts | 51 +-
web/src/pages/settings/AiAssistantTab.tsx | 35 ++
.../settings/__tests__/AiAssistantTab.test.tsx | 18 +
web/src/pages/studio/GroupManagement.tsx | 576 ---------------------
.../studio/__tests__/GroupManagement.test.tsx | 399 --------------
15 files changed, 200 insertions(+), 1021 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
index 41c96f67d..9cb0d077f 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
@@ -42,11 +42,13 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
+import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
+import java.util.Set;
@Slf4j
@Service
@@ -81,7 +83,7 @@ public class ClusterService {
log.info("Listing all clusters");
List<ClusterVO> discovered = clusterProvider.discoverClusters();
if (discovered != null && !discovered.isEmpty()) {
- discovered.forEach(this::enrichWithLiveConfig);
+ enrichDiscoveredClusters(discovered, null);
return discovered;
}
return List.of();
@@ -122,7 +124,7 @@ public class ClusterService {
log.info("Listing clusters for instance: {}", instanceId);
List<ClusterVO> discovered =
clusterProvider.discoverClusters(instanceId);
if (discovered != null && !discovered.isEmpty()) {
- discovered.forEach(cluster -> enrichWithLiveConfig(cluster,
instanceId));
+ enrichDiscoveredClusters(discovered, instanceId);
return discovered;
}
return List.of();
@@ -191,9 +193,35 @@ public class ClusterService {
}
private void enrichWithLiveConfig(ClusterVO cluster, String instanceId) {
+ enrichWithLiveConfig(cluster, instanceId, Set.of());
+ }
+
+ /**
+ * Enriches a discovered cluster list without repeating broker-config
reads for the same
+ * broker address. Multiple registry entries can expose the same
underlying clusters;
+ * once an address fails, later duplicates should use the persisted
fallback immediately.
+ */
+ private void enrichDiscoveredClusters(List<ClusterVO> clusters, String
instanceId) {
+ Set<String> attemptedAddresses = new LinkedHashSet<>();
+ for (ClusterVO cluster : clusters) {
+ if (cluster == null) {
+ continue;
+ }
+ enrichWithLiveConfig(cluster, instanceId, attemptedAddresses);
+ if (cluster.getBrokers() != null) {
+ cluster.getBrokers().stream()
+ .map(BrokerVO::getAddr)
+ .filter(address -> address != null &&
!address.isEmpty())
+ .forEach(attemptedAddresses::add);
+ }
+ }
+ }
+
+ private void enrichWithLiveConfig(ClusterVO cluster, String instanceId,
Set<String> attemptedAddresses) {
if (cluster.getBrokers() != null) {
for (BrokerVO broker : cluster.getBrokers()) {
- if (broker.getAddr() != null && !broker.getAddr().isEmpty()) {
+ if (broker.getAddr() != null && !broker.getAddr().isEmpty()
+ && !attemptedAddresses.contains(broker.getAddr())) {
try {
cluster.setConfig(brokerConfigService.getBrokerConfig(broker.getAddr(),
instanceId));
return;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
index 0b939aa1e..31522bc17 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
@@ -42,12 +42,20 @@ public class MessageService {
public List<MessageRecordVO> queryMessages(
String instanceId, String topic, String msgId, String tag, String
key, Long startTime, Long endTime) {
+ return queryMessages(instanceId, topic, msgId, tag, key, startTime,
endTime, true);
+ }
+
+ private List<MessageRecordVO> queryMessages(
+ String instanceId, String topic, String msgId, String tag, String
key,
+ Long startTime, Long endTime, boolean recordHistory) {
validateTopicQueryWindow(topic, msgId, key, startTime, endTime);
log.info("Querying messages: topic={}, msgId={}, tag={}, key={}",
topic, msgId, tag, key);
List<MessageRecordVO> result =
providerRegistry.byInstanceId(instanceId)
.map(provider -> provider.queryMessages(instanceId, topic,
msgId, tag, key, startTime, endTime))
.orElseGet(() -> messageProvider.queryMessages(instanceId,
topic, msgId, tag, key, startTime, endTime));
- recordMessageQuery(instanceId, topic, msgId, tag, key, startTime,
endTime, result.size());
+ if (recordHistory) {
+ recordMessageQuery(instanceId, topic, msgId, tag, key, startTime,
endTime, result.size());
+ }
return result;
}
@@ -56,7 +64,8 @@ public class MessageService {
if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
throw new BusinessException(400, "page must be positive and
pageSize must be between 1 and 200");
}
- List<MessageRecordVO> result = queryMessages(instanceId, topic, msgId,
tag, key, startTime, endTime);
+ List<MessageRecordVO> result = queryMessages(
+ instanceId, topic, msgId, tag, key, startTime, endTime, page
== 1);
long offset = (long) (page - 1) * pageSize;
int from = (int) Math.min(offset, result.size());
int to = Math.min(from + pageSize, result.size());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigDTO.java
index 938313438..2b0caa919 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigDTO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigDTO.java
@@ -26,6 +26,7 @@ public class LlmConfigDTO {
private String engine;
@ToString.Exclude
private String apiKey;
+ private boolean clearApiKey;
private String apiBase;
private String model;
private int maxTokens;
@@ -40,6 +41,7 @@ public class LlmConfigDTO {
.provider(provider)
.engine(engine)
.apiKey(apiKey)
+ .clearApiKey(clearApiKey)
.apiBase(apiBase)
.model(model)
.maxTokens(maxTokens)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
index d7218f599..ee76bfefc 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigService.java
@@ -276,10 +276,15 @@ public class LlmConfigService {
private LlmConfigVO normalize(LlmConfigVO config) {
String provider = normalizeProvider(config == null ? null :
config.getProvider());
+ boolean clearApiKey = config != null && config.isClearApiKey();
+ String apiKey = clearApiKey
+ ? ""
+ : defaultString(config == null ? null : config.getApiKey(),
"");
return LlmConfigVO.builder()
.provider(provider)
.engine(normalizeEngine(config == null ? null :
config.getEngine()))
- .apiKey(defaultString(config == null ? null :
config.getApiKey(), ""))
+ .apiKey(apiKey)
+ .clearApiKey(clearApiKey)
.apiBase(normalizeApiBase(defaultString(config == null ? null
: config.getApiBase(),
defaultApiBase(provider))))
.model(defaultString(config == null ? null :
config.getModel(), defaultModel(provider)))
@@ -298,6 +303,10 @@ public class LlmConfigService {
private LlmConfigVO normalizeWithStoredApiKey(LlmConfigVO config) {
LlmConfigVO normalized = normalize(config);
+ if (normalized.isClearApiKey()) {
+ normalized.setApiKey("");
+ return normalized;
+ }
if (!requiresApiKey(normalized.getProvider())) {
normalized.setApiKey("");
return normalized;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigVO.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigVO.java
index f5f1587b1..74a683b1b 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigVO.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/LlmConfigVO.java
@@ -41,6 +41,8 @@ public class LlmConfigVO {
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@ToString.Exclude
private String apiKey;
+ @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
+ private boolean clearApiKey;
private String apiBase;
private String model;
private int maxTokens;
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceTest.java
index 2dd9e7614..e175170df 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceTest.java
@@ -145,6 +145,39 @@ class ClusterServiceTest {
verify(clusterProvider, never()).discoverClusters();
}
+ @Test
+ void listClustersShouldNotRepeatBrokerConfigReadsForDuplicateBrokers() {
+ ClusterVO duplicateCluster = ClusterVO.builder()
+ .name("duplicate-cluster")
+ .status(ClusterStatus.warning)
+ .brokers(List.of(BrokerVO.builder()
+ .name("broker-0")
+ .addr("10.0.0.1:10911")
+ .build()))
+ .build();
+ duplicateCluster.setId("cluster-2");
+ sampleCluster.setConfig(null);
+
when(clusterProvider.discoverClusters()).thenReturn(List.of(sampleCluster,
duplicateCluster));
+ when(brokerConfigService.getBrokerConfig("10.0.0.1:10911", null))
+ .thenThrow(new BusinessException(502, "broker unavailable"));
+ when(clusterRepository.findById("cluster-1"))
+ .thenReturn(Optional.of(ClusterVO.builder()
+
.config(ClusterConfigVO.builder().maxMessageSize(1024).build())
+ .build()));
+ when(clusterRepository.findById("cluster-2"))
+ .thenReturn(Optional.of(ClusterVO.builder()
+
.config(ClusterConfigVO.builder().maxMessageSize(2048).build())
+ .build()));
+
+ List<ClusterVO> result = clusterService.listClusters();
+
+ assertThat(result).hasSize(2);
+
assertThat(result.get(0).getConfig().getMaxMessageSize()).isEqualTo(1024);
+
assertThat(result.get(1).getConfig().getMaxMessageSize()).isEqualTo(2048);
+ verify(brokerConfigService, org.mockito.Mockito.times(1))
+ .getBrokerConfig("10.0.0.1:10911", null);
+ }
+
@Test
void
updateClusterConfigShouldRejectDifferentDefaultReadAndWriteQueueNums() {
UpdateConfigDTO command = UpdateConfigDTO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
index a409242d9..53b9b4571 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
@@ -186,4 +186,25 @@ class MessageServiceTest {
assertThat(page.getTotal()).isEqualTo(200);
assertThat(page.isResultMayBeTruncated()).isTrue();
}
+
+ @Test
+ void pageQueryRecordsHistoryOnlyForTheFirstPage() {
+ MessageProvider provider = mock(MessageProvider.class);
+ InstanceProviderRegistry registry =
mock(InstanceProviderRegistry.class);
+ QueryHistoryService history = mock(QueryHistoryService.class);
+ MessageService service = new MessageService(provider, registry,
history, mock(OperationAuditService.class));
+ when(registry.byInstanceId("instance-a")).thenReturn(Optional.empty());
+ when(provider.queryMessages("instance-a", "TopicA", null, null, null,
1000L, 2000L))
+
.thenReturn(List.of(MessageRecordVO.builder().msgId("msg-1").build()));
+
+ service.queryMessagesPage("instance-a", "TopicA", null, null, null,
+ 1000L, 2000L, 1, 50);
+ service.queryMessagesPage("instance-a", "TopicA", null, null, null,
+ 1000L, 2000L, 2, 50);
+
+ verify(provider, org.mockito.Mockito.times(2))
+ .queryMessages("instance-a", "TopicA", null, null, null,
1000L, 2000L);
+ verify(history, org.mockito.Mockito.times(1)).recordMessageQuery(
+ "instance-a", "TOPIC", "TopicA", null, null, null, 1000L,
2000L, 1);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
index e5fd2b2a1..d30d3ca51 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/LlmConfigServiceTest.java
@@ -260,6 +260,25 @@ class LlmConfigServiceTest {
assertThat(captor.getValue().getApiKey()).isEqualTo("sk-test");
}
+ @Test
+ void saveConfigShouldClearStoredApiKeyWhenRequested() {
+ llmConfigService.saveConfig(LlmConfigVO.builder()
+ .provider("deepseek")
+ .clearApiKey(true)
+ .apiBase("https://api.deepseek.com/v1")
+ .model("deepseek-chat")
+ .maxTokens(8192)
+ .temperature(0.2)
+ .enabled(true)
+ .build());
+
+ ArgumentCaptor<GeneralSettingsVO> captor =
ArgumentCaptor.forClass(GeneralSettingsVO.class);
+ verify(settingsService).saveGeneralSettings(captor.capture());
+ assertThat(captor.getValue().getApiKey()).isBlank();
+ assertThat(llmConfigService.getConfig().getApiKey()).isBlank();
+
assertThat(llmConfigService.getConfig().isApiKeyConfigured()).isFalse();
+ }
+
@Test
void saveConfigShouldNormalizeChatCompletionsEndpointToApiBase() {
llmConfigService.saveConfig(LlmConfigVO.builder()
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 540072061..feae211e2 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -43,7 +43,6 @@ const AiPage = lazy(() => import('./pages/ai'));
const SettingsPage = lazy(() => import('./pages/settings'));
const ProxyPage = lazy(() => import('./pages/studio/Proxy'));
const LiteTopicPage = lazy(() => import('./pages/studio/LiteTopic'));
-const GroupManagementPage = lazy(() =>
import('./pages/studio/GroupManagement'));
const BrokerClusterPage = lazy(() => import('./pages/studio/BrokerCluster'));
const GrafanaDashboardsPage = lazy(() =>
import('./pages/studio/GrafanaDashboards'));
const ProducerPage = lazy(() => import('./pages/studio/Producer'));
@@ -191,7 +190,10 @@ function App() {
<Route path="settings" element={<SettingsPage />} />
<Route path="studio/proxy" element={<ProxyPage />} />
<Route path="studio/lite-topic" element={<LiteTopicPage />} />
- <Route path="studio/group-management"
element={<GroupManagementPage />} />
+ <Route
+ path="studio/group-management"
+ element={<Navigate to="/instance/consumer" replace />}
+ />
<Route path="studio/broker-cluster" element={<BrokerClusterPage
/>} />
<Route path="studio/alert-management" element={<Navigate
to="/ops/alerts" replace />} />
<Route path="studio/producer" element={<ProducerPage />} />
diff --git a/web/src/api/llm.ts b/web/src/api/llm.ts
index 2c29e65aa..13de4b238 100644
--- a/web/src/api/llm.ts
+++ b/web/src/api/llm.ts
@@ -21,6 +21,7 @@ export interface LlmConfig {
provider: string;
engine?: string;
apiKey?: string;
+ clearApiKey?: boolean;
apiKeyConfigured?: boolean;
apiBase: string;
model: string;
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 455a0e185..843a3e82a 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -816,6 +816,19 @@ const translations: Record<string, Record<Lang, string>> =
{
en: '•••••••• (configured; leave empty to keep)',
},
'settings.apiKeyConfigured': { zh: '密钥已配置', en: 'API key configured' },
+ 'settings.clearApiKey': { zh: '清除密钥', en: 'Clear key' },
+ 'settings.clearApiKeyConfirm': {
+ zh: '确定清除已保存的 LLM API Key 吗?通过 RMQ_LLM_TOKEN 注入的密钥不会被删除。',
+ en: 'Clear the saved LLM API key? A key injected through RMQ_LLM_TOKEN
will not be removed.',
+ },
+ 'settings.clearApiKeySucceeded': {
+ zh: 'LLM API Key 已清除',
+ en: 'LLM API key cleared',
+ },
+ 'settings.clearApiKeyFailed': {
+ zh: '清除 LLM API Key 失败',
+ en: 'Failed to clear the LLM API key',
+ },
'settings.apiBaseRequired': { zh: '请输入 API Base URL', en: 'Enter an API base
URL.' },
'settings.apiBaseInvalid': { zh: '需为 http/https 地址', en: 'Use an http or
https URL.' },
'settings.generationParameters': { zh: '生成参数', en: 'Generation parameters' },
@@ -1410,44 +1423,6 @@ const translations: Record<string, Record<Lang, string>>
= {
'brokerCluster.grpcAddr': { zh: 'gRPC 地址', en: 'gRPC Address' },
'brokerCluster.connections': { zh: '连接数', en: 'Connections' },
- // ─── Group Management ───
- 'groupMgmt.title': { zh: '消费组管理', en: 'Consumer Group Management' },
- 'groupMgmt.groupName': { zh: '消费组名称', en: 'Group Name' },
- 'groupMgmt.namespace': { zh: '命名空间', en: 'Namespace' },
- 'groupMgmt.cluster': { zh: '集群', en: 'Cluster' },
- 'groupMgmt.onlineInstances': { zh: '在线实例', en: 'Online Instances' },
- 'groupMgmt.consumeMode': { zh: '消费模式', en: 'Consume Mode' },
- 'groupMgmt.clustering': { zh: '集群消费', en: 'Clustering' },
- 'groupMgmt.broadcasting': { zh: '广播消费', en: 'Broadcasting' },
- 'groupMgmt.diff': { zh: '堆积量', en: 'Diff' },
- 'groupMgmt.backlogAlert': { zh: '堆积告警', en: 'Backlog Alert' },
- 'groupMgmt.stopped': { zh: '已停止', en: 'Stopped' },
- 'groupMgmt.createGroup': { zh: '创建消费组', en: 'Create Group' },
- 'groupMgmt.searchPlaceholder': { zh: '搜索消费组', en: 'Search group' },
- 'groupMgmt.providerUnavailable': {
- zh: '当前版本尚未接入真实消费组管理接口,已停止展示模拟消费组数据。',
- en: 'The real consumer group management API is not connected yet, so mock
consumer group data is no longer displayed.',
- },
- 'groupMgmt.manual': { zh: '手动', en: 'Manual' },
- 'groupMgmt.overview': { zh: '概览', en: 'Overview' },
- 'groupMgmt.totalDiff': { zh: '总堆积量', en: 'Total Diff' },
- 'groupMgmt.subscribedTopics': { zh: '订阅主题', en: 'Subscribed Topics' },
- 'groupMgmt.online': { zh: '在线', en: 'Online' },
- 'groupMgmt.consumeType': { zh: '消费类型', en: 'Consume Type' },
- 'groupMgmt.consumeDelay': { zh: '消费延迟', en: 'Consume Delay' },
- 'groupMgmt.maxRetry': { zh: '最大重试', en: 'Max Retry' },
- 'groupMgmt.createdAt': { zh: '创建时间', en: 'Created At' },
- 'groupMgmt.subscription': { zh: '订阅关系', en: 'Subscription' },
- 'groupMgmt.topic': { zh: '主题', en: 'Topic' },
- 'groupMgmt.consistency': { zh: '一致性', en: 'Consistency' },
- 'groupMgmt.consistent': { zh: '一致', en: 'Consistent' },
- 'groupMgmt.inconsistent': { zh: '不一致', en: 'Inconsistent' },
- 'groupMgmt.subMode': { zh: '订阅模式', en: 'Sub Mode' },
- 'groupMgmt.expression': { zh: '过滤表达式', en: 'Expression' },
- 'groupMgmt.viewDistribution': { zh: '查看分布', en: 'View Distribution' },
- 'groupMgmt.instanceId': { zh: '实例 ID', en: 'Instance ID' },
- 'groupMgmt.consumeProgress': { zh: '消费进度', en: 'Consume Progress' },
-
// ─── SSL Settings ───
'ssl.title': { zh: 'SSL/TLS 设置', en: 'SSL/TLS Settings' },
'ssl.info': { zh: 'SSL/TLS 配置', en: 'SSL/TLS Configuration' },
diff --git a/web/src/pages/settings/AiAssistantTab.tsx
b/web/src/pages/settings/AiAssistantTab.tsx
index d0d80d901..3b7b16ce9 100644
--- a/web/src/pages/settings/AiAssistantTab.tsx
+++ b/web/src/pages/settings/AiAssistantTab.tsx
@@ -222,6 +222,32 @@ export const AiAssistantTab = () => {
};
};
+ const handleClearApiKey = async () => {
+ const payload = await buildPayload();
+ if (!payload) return;
+ const confirmed = window.confirm(t('settings.clearApiKeyConfirm'));
+ if (!confirmed) return;
+ setSaving(true);
+ try {
+ const result = await saveLlmConfig({
+ ...payload,
+ apiKey: undefined,
+ clearApiKey: true,
+ });
+ if (result.status === 0) {
+ message.success(t('settings.clearApiKeySucceeded'));
+ setApiKeyConfigured(false);
+ form.setFieldValue('apiKey', undefined);
+ } else {
+ message.error(result.errMsg || t('settings.clearApiKeyFailed'));
+ }
+ } catch {
+ message.error(t('settings.clearApiKeyFailed'));
+ } finally {
+ setSaving(false);
+ }
+ };
+
const applyTestResult = (result: LlmTestResult) => {
if (result.status === 0) {
setTestResult({ success: true, msg: result.msg ||
t('settings.connectionSucceeded') });
@@ -374,6 +400,15 @@ export const AiAssistantTab = () => {
{apiKeyConfigured && (
<div style={{ marginTop: -16, marginBottom: 16 }}>
<Tag color="green">{t('settings.apiKeyConfigured')}</Tag>
+ <Button
+ danger
+ size="small"
+ style={{ marginLeft: 8 }}
+ loading={saving}
+ onClick={() => void handleClearApiKey()}
+ >
+ {t('settings.clearApiKey')}
+ </Button>
</div>
)}
diff --git a/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
b/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
index 7bb6aad54..ecdc747fa 100644
--- a/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
+++ b/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
@@ -106,6 +106,24 @@ describe('AiAssistantTab', () => {
expect(payload.apiKey).toBeUndefined();
});
+ it('clears the stored API key after confirmation', async () => {
+ const user = userEvent.setup();
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+ renderPage();
+
+ await screen.findByText('密钥已配置');
+ await user.click(screen.getByRole('button', { name: /清除密钥/ }));
+
+ await waitFor(() =>
expect(llmApiMocks.saveLlmConfig).toHaveBeenCalledTimes(1));
+ expect(llmApiMocks.saveLlmConfig.mock.calls[0][0]).toMatchObject({
+ clearApiKey: true,
+ provider: 'tongyi',
+ });
+ expect(llmApiMocks.saveLlmConfig.mock.calls[0][0].apiKey).toBeUndefined();
+ expect(await screen.findByText('LLM API Key 已清除')).toBeInTheDocument();
+ expect(screen.queryByText('密钥已配置')).not.toBeInTheDocument();
+ });
+
it('submits the Azure deployment fields required by the backend', async ()
=> {
const user = userEvent.setup();
const { container } = renderPage();
diff --git a/web/src/pages/studio/GroupManagement.tsx
b/web/src/pages/studio/GroupManagement.tsx
deleted file mode 100644
index e4125f581..000000000
--- a/web/src/pages/studio/GroupManagement.tsx
+++ /dev/null
@@ -1,576 +0,0 @@
-/*
- * 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.
- */
-
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
- Table,
- Button,
- Input,
- Tag,
- Modal,
- Tabs,
- Card,
- Row,
- Col,
- Descriptions,
- Space,
- Switch,
- message,
- Alert,
-} from 'antd';
-import { MagnifyingGlass, ArrowClockwise, Users } from '@phosphor-icons/react';
-import { useLang } from '../../i18n/LangContext';
-import type { ConsumerGroup, QueueProgress, SubscriptionEntry } from
'../../api/metadata';
-import {
- getConsumerProgress,
- getConsumerSubscriptions,
- listConsumerGroups,
-} from '../../services/consumerService';
-import { useVisiblePolling } from '../../hooks/useVisiblePolling';
-
-// ─── Helpers ────────────────────────────────────────────────────
-type GroupStatus = 'running' | 'warning' | 'stopped';
-
-const BACKLOG_WARNING_THRESHOLD = 10000;
-const GROUP_REFRESH_INTERVAL_MS = 2000;
-
-const deriveStatus = (group: ConsumerGroup): GroupStatus => {
- if (group.onlineInstances <= 0) return 'stopped';
- if (group.totalLag > BACKLOG_WARNING_THRESHOLD) return 'warning';
- return 'running';
-};
-
-const isConsistent = (consistency: string): boolean =>
- consistency === 'consistent' || consistency === '一致';
-
-// ─── Component ──────────────────────────────────────────────────
-const GroupManagementPage = () => {
- const [searchText, setSearchText] = useState('');
- const [modalVisible, setModalVisible] = useState(false);
- const [selectedGroup, setSelectedGroup] = useState<ConsumerGroup |
null>(null);
- const [autoRefresh, setAutoRefresh] = useState(false);
- const [groups, setGroups] = useState<ConsumerGroup[]>([]);
- const [loading, setLoading] = useState(true);
- const [subscriptions, setSubscriptions] = useState<SubscriptionEntry[]>([]);
- const [progress, setProgress] = useState<QueueProgress[]>([]);
- const [subscriptionLoading, setSubscriptionLoading] = useState(false);
- const [progressLoading, setProgressLoading] = useState(false);
- const [subscriptionError, setSubscriptionError] = useState<string |
null>(null);
- const [progressError, setProgressError] = useState<string | null>(null);
- const [currentPage, setCurrentPage] = useState(1);
- const [pageSize, setPageSize] = useState(10);
- const listRequestId = useRef(0);
- const listInFlight = useRef<Promise<void> | null>(null);
- const listRefreshQueued = useRef(false);
- const mountedRef = useRef(true);
- const detailRequestId = useRef(0);
- const { t } = useLang();
-
- const loadGroups = useCallback((): Promise<void> => {
- if (listInFlight.current) {
- listRefreshQueued.current = true;
- return listInFlight.current;
- }
-
- setLoading(true);
- const run = async () => {
- do {
- listRefreshQueued.current = false;
- const requestId = ++listRequestId.current;
- try {
- const data = await listConsumerGroups();
- if (!mountedRef.current || requestId !== listRequestId.current)
return;
- setGroups(data);
- } catch {
- if (!mountedRef.current || requestId !== listRequestId.current)
return;
- message.error(t('consumer.fetchListFailed'));
- }
- } while (mountedRef.current && listRefreshQueued.current);
- };
-
- const cycle = run().finally(() => {
- listInFlight.current = null;
- if (mountedRef.current) {
- setLoading(false);
- }
- });
- listInFlight.current = cycle;
- return cycle;
- }, [t]);
-
- useEffect(() => {
- // Reset on (re)mount: under StrictMode the previous cleanup has already
- // cleared the flag, and without this the remounted load never applies.
- mountedRef.current = true;
- const requestId = listRequestId.current;
- const timeoutId = window.setTimeout(() => {
- void loadGroups();
- });
- return () => {
- window.clearTimeout(timeoutId);
- mountedRef.current = false;
- listRefreshQueued.current = false;
- listRequestId.current = requestId + 1;
- };
- }, [loadGroups]);
-
- useVisiblePolling(autoRefresh, GROUP_REFRESH_INTERVAL_MS, loadGroups);
-
- const handleRefresh = useCallback(() => {
- void loadGroups();
- }, [loadGroups]);
-
- const handleViewDetail = useCallback(
- async (group: ConsumerGroup) => {
- const requestId = ++detailRequestId.current;
- setSelectedGroup(group);
- setModalVisible(true);
- setSubscriptions([]);
- setProgress([]);
- setSubscriptionError(null);
- setProgressError(null);
- setSubscriptionLoading(true);
- setProgressLoading(true);
-
- const subscriptionRequest = getConsumerSubscriptions(group.name,
group.instanceId)
- .then(
- (result) => {
- if (requestId === detailRequestId.current)
setSubscriptions(result);
- },
- () => {
- if (requestId === detailRequestId.current) {
- setSubscriptionError(t('consumer.fetchSubscriptionsFailed', {
name: group.name }));
- }
- },
- )
- .finally(() => {
- if (requestId === detailRequestId.current)
setSubscriptionLoading(false);
- });
- const progressRequest = getConsumerProgress(group.name, group.instanceId)
- .then(
- (result) => {
- if (requestId === detailRequestId.current) setProgress(result);
- },
- () => {
- if (requestId === detailRequestId.current) {
- setProgressError(t('consumer.fetchProgressFailed', { name:
group.name }));
- }
- },
- )
- .finally(() => {
- if (requestId === detailRequestId.current) setProgressLoading(false);
- });
-
- await Promise.all([subscriptionRequest, progressRequest]);
- },
- [t],
- );
-
- const normalizedSearchText = searchText.trim().toLowerCase();
- const filteredGroupData = useMemo(
- () =>
- groups.filter(
- (record) =>
- !normalizedSearchText ||
record.name.toLowerCase().includes(normalizedSearchText),
- ),
- [groups, normalizedSearchText],
- );
-
- const lastPage = Math.max(1, Math.ceil(filteredGroupData.length / pageSize));
- const clampedCurrentPage = Math.min(currentPage, lastPage);
-
- const columns = [
- {
- title: t('groupMgmt.groupName'),
- dataIndex: 'name',
- key: 'name',
- render: (text: string, record: ConsumerGroup) => (
- <a
- onClick={() => void handleViewDetail(record)}
- style={{ color: '#1677ff', fontWeight: 500, whiteSpace: 'nowrap' }}
- >
- {text}
- </a>
- ),
- },
- { title: t('groupMgmt.namespace'), dataIndex: 'namespace', key:
'namespace' },
- { title: t('groupMgmt.cluster'), dataIndex: 'clusterId', key: 'clusterId'
},
- {
- title: t('groupMgmt.onlineInstances'),
- dataIndex: 'onlineInstances',
- key: 'onlineInstances',
- render: (count: number) => <span style={{ fontWeight: 500
}}>{count}</span>,
- },
- {
- title: t('groupMgmt.consumeMode'),
- dataIndex: 'consumeType',
- key: 'consumeType',
- render: (mode: string) => (
- <Tag color={mode === 'CLUSTERING' ? 'blue' : 'orange'}>
- {mode === 'CLUSTERING' ? t('groupMgmt.clustering') :
t('groupMgmt.broadcasting')}
- </Tag>
- ),
- },
- {
- title: t('groupMgmt.diff'),
- dataIndex: 'totalLag',
- key: 'totalLag',
- render: (diff: number) => (
- <span
- style={{
- color: diff > 10000 ? '#ff4d4f' : diff > 0 ? '#fa8c16' : '#52c41a',
- fontWeight: 500,
- }}
- >
- {diff.toLocaleString()}
- </span>
- ),
- },
- {
- title: t('brokerCluster.status'),
- key: 'status',
- render: (_: unknown, record: ConsumerGroup) => {
- const status = deriveStatus(record);
- const config: Record<GroupStatus, { color: string; label: string }> = {
- running: { color: 'success', label: t('brokerCluster.statusRunning')
},
- warning: { color: 'warning', label: t('groupMgmt.backlogAlert') },
- stopped: { color: 'error', label: t('groupMgmt.stopped') },
- };
- const { color, label } = config[status];
- return <Tag color={color}>{label}</Tag>;
- },
- },
- {
- title: t('common.actions'),
- key: 'action',
- render: (_: unknown, record: ConsumerGroup) => (
- <Button type="link" size="small" onClick={() => void
handleViewDetail(record)}>
- {t('common.detail')}
- </Button>
- ),
- },
- ];
-
- const subscriptionColumns = [
- {
- title: t('groupMgmt.topic'),
- dataIndex: 'topic',
- key: 'topic',
- render: (text: string) => <span style={{ fontWeight: 500
}}>{text}</span>,
- },
- {
- title: t('groupMgmt.consistency'),
- dataIndex: 'consistency',
- key: 'consistency',
- render: (consistency: string) => (
- <Tag color={isConsistent(consistency) ? 'success' : 'warning'}>
- {isConsistent(consistency) ? t('groupMgmt.consistent') :
t('groupMgmt.inconsistent')}
- </Tag>
- ),
- },
- { title: t('groupMgmt.subMode'), dataIndex: 'filterMode', key:
'filterMode' },
- {
- title: t('groupMgmt.expression'),
- dataIndex: 'expression',
- key: 'expression',
- render: (text: string) => (
- <code style={{ background: '#f5f5f5', padding: '2px 6px',
borderRadius: 4, fontSize: 14 }}>
- {text}
- </code>
- ),
- },
- ];
-
- return (
- <div style={{ padding: 0 }}>
- <div
- style={{
- display: 'flex',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: 20,
- }}
- >
- <h2
- style={{
- fontSize: 20,
- fontWeight: 600,
- margin: 0,
- display: 'flex',
- alignItems: 'center',
- }}
- >
- <Users size={22} style={{ marginRight: 8, color: '#1677ff' }} />
- {t('groupMgmt.title')}
- </h2>
- <Space size="middle">
- <Input
- placeholder={t('groupMgmt.searchPlaceholder')}
- prefix={<MagnifyingGlass size={14} />}
- value={searchText}
- onChange={(e) => {
- setSearchText(e.target.value);
- setCurrentPage(1);
- }}
- style={{ width: 240 }}
- allowClear
- />
- <Switch
- checked={autoRefresh}
- onChange={setAutoRefresh}
- checkedChildren={t('common.autoRefresh')}
- unCheckedChildren={t('groupMgmt.manual')}
- size="small"
- />
- <Button icon={<ArrowClockwise size={14} />} size="small"
onClick={handleRefresh}>
- {t('common.refresh')}
- </Button>
- </Space>
- </div>
-
- <Card
- variant="borderless"
- style={{ borderRadius: 8, boxShadow: '0 1px 6px rgba(0,0,0,0.04)' }}
- >
- <Table
- columns={columns}
- dataSource={filteredGroupData}
- rowKey={(record) =>
- `${record.instanceId || record.clusterId ||
'unscoped'}\0${record.name}`
- }
- loading={loading}
- pagination={{
- current: clampedCurrentPage,
- pageSize,
- onChange: (page, nextPageSize) => {
- setCurrentPage(page);
- setPageSize(nextPageSize);
- },
- showTotal: (total) => `${t('common.total')} ${total} Group`,
- showSizeChanger: true,
- }}
- size="middle"
- />
- </Card>
-
- <Modal
- title={null}
- open={modalVisible}
- onCancel={() => {
- ++detailRequestId.current;
- setModalVisible(false);
- }}
- footer={null}
- width={720}
- destroyOnHidden
- >
- <div style={{ marginBottom: 16 }}>
- <h3 style={{ margin: 0, display: 'flex', alignItems: 'center' }}>
- <Users size={18} style={{ marginRight: 8, color: '#1677ff' }} />
- {selectedGroup?.name}
- </h3>
- </div>
- {selectedGroup && (
- <Tabs
- defaultActiveKey="overview"
- items={[
- {
- key: 'overview',
- label: t('groupMgmt.overview'),
- children: (
- <div>
- <Row gutter={16} style={{ marginBottom: 20 }}>
- <Col span={8}>
- <Card variant="borderless" style={{ background:
'#f6ffed' }}>
- <div style={{ color: '#666', fontSize: 14 }}>
- {t('groupMgmt.onlineInstances')}
- </div>
- <div style={{ fontSize: 24, fontWeight: 600 }}>
- {selectedGroup.onlineInstances}{' '}
- <Tag
- color={selectedGroup.onlineInstances > 0 ?
'success' : 'error'}
- style={{ marginLeft: 8 }}
- >
- {selectedGroup.onlineInstances > 0
- ? t('groupMgmt.online')
- : t('groupMgmt.stopped')}
- </Tag>
- </div>
- </Card>
- </Col>
- <Col span={8}>
- <Card variant="borderless" style={{ background:
'#fff2f0' }}>
- <div style={{ color: '#666', fontSize: 14 }}>
- {t('groupMgmt.totalDiff')}
- </div>
- <div style={{ fontSize: 24, fontWeight: 600, color:
'#ff4d4f' }}>
- {selectedGroup.totalLag.toLocaleString()}
- </div>
- </Card>
- </Col>
- <Col span={8}>
- <Card variant="borderless" style={{ background:
'#f0f5ff' }}>
- <div style={{ color: '#666', fontSize: 14 }}>
- {t('groupMgmt.subscribedTopics')}
- </div>
- <div style={{ fontSize: 24, fontWeight: 600 }}>
- {(selectedGroup.subscribedTopics ?? []).length}
- </div>
- </Card>
- </Col>
- </Row>
- <Descriptions column={2} bordered size="small">
- <Descriptions.Item label={t('groupMgmt.groupName')}>
- {selectedGroup.name}
- </Descriptions.Item>
- <Descriptions.Item label={t('groupMgmt.namespace')}>
- {selectedGroup.namespace}
- </Descriptions.Item>
- <Descriptions.Item label={t('groupMgmt.cluster')}>
- {selectedGroup.clusterId}
- </Descriptions.Item>
- <Descriptions.Item label={t('groupMgmt.consumeMode')}>
- <Tag color={selectedGroup.consumeType === 'CLUSTERING'
? 'blue' : 'orange'}>
- {selectedGroup.consumeType === 'CLUSTERING'
- ? t('groupMgmt.clustering')
- : t('groupMgmt.broadcasting')}
- </Tag>
- </Descriptions.Item>
- <Descriptions.Item label={t('groupMgmt.consumeType')}>
- {selectedGroup.subscriptionMode}
- </Descriptions.Item>
- <Descriptions.Item label={t('groupMgmt.consumeDelay')}>
- {selectedGroup.delaySeconds.toLocaleString()}s
- </Descriptions.Item>
- <Descriptions.Item label={t('groupMgmt.maxRetry')}>
- {selectedGroup.retryMaxTimes}
- </Descriptions.Item>
- <Descriptions.Item label={t('groupMgmt.createdAt')}>
- {selectedGroup.gmtCreate}
- </Descriptions.Item>
- <Descriptions.Item
label={t('groupMgmt.subscribedTopics')} span={2}>
- {(selectedGroup.subscribedTopics ?? []).join(', ')}
- </Descriptions.Item>
- </Descriptions>
- <h4 style={{ marginTop: 20, marginBottom: 12 }}>
- {t('groupMgmt.subscription')}
- </h4>
- {subscriptionError && (
- <Alert
- type="error"
- showIcon
- message={subscriptionError}
- style={{ marginBottom: 12 }}
- />
- )}
- <Table
- columns={subscriptionColumns}
- dataSource={subscriptions}
- rowKey="topic"
- loading={subscriptionLoading}
- pagination={false}
- size="small"
- />
- </div>
- ),
- },
- {
- key: 'instances',
- label: t('groupMgmt.onlineInstances'),
- children: (
- <Table
- columns={[
- {
- title: t('groupMgmt.instanceId'),
- dataIndex: 'clientId',
- key: 'clientId',
- },
- { title: t('common.address'), dataIndex: 'address', key:
'address' },
- { title: t('brokerCluster.version'), dataIndex:
'protocol', key: 'protocol' },
- {
- title: t('brokerCluster.status'),
- key: 'status',
- render: () => <Tag
color="success">{t('groupMgmt.online')}</Tag>,
- },
- ]}
- dataSource={selectedGroup.instances}
- rowKey="clientId"
- pagination={false}
- size="small"
- />
- ),
- },
- {
- key: 'progress',
- label: t('groupMgmt.consumeProgress'),
- children: (
- <>
- {progressError && (
- <Alert
- type="error"
- showIcon
- message={progressError}
- style={{ marginBottom: 12 }}
- />
- )}
- <Table
- columns={[
- { title: 'Broker', dataIndex: 'broker', key: 'broker'
},
- { title: 'QueueId', dataIndex: 'queueId', key:
'queueId' },
- {
- title: 'Broker Offset',
- dataIndex: 'brokerOffset',
- key: 'brokerOffset',
- render: (v: number) => v.toLocaleString(),
- },
- {
- title: 'Consumer Offset',
- dataIndex: 'consumerOffset',
- key: 'consumerOffset',
- render: (v: number) => v.toLocaleString(),
- },
- {
- title: 'Diff',
- dataIndex: 'diffTotal',
- key: 'diffTotal',
- render: (v: number) => (
- <span
- style={{ color: v > 100 ? '#ff4d4f' : '#52c41a',
fontWeight: 500 }}
- >
- {v.toLocaleString()}
- </span>
- ),
- },
- ]}
- dataSource={progress}
- rowKey={(record) => `${record.broker}-${record.queueId}`}
- loading={progressLoading}
- pagination={false}
- size="small"
- />
- </>
- ),
- },
- ]}
- />
- )}
- </Modal>
- </div>
- );
-};
-
-export default GroupManagementPage;
diff --git a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
deleted file mode 100644
index ed4edffde..000000000
--- a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
+++ /dev/null
@@ -1,399 +0,0 @@
-/*
- * 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.
- */
-
-import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from
'vitest';
-import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { App } from 'antd';
-import { LangProvider } from '../../../i18n/LangContext';
-import type { ConsumerGroup, QueueProgress, SubscriptionEntry } from
'../../../api/metadata';
-import * as consumerService from '../../../services/consumerService';
-import GroupManagement from '../GroupManagement';
-
-vi.mock('../../../services/consumerService', () => ({
- listConsumerGroups: vi.fn(),
- getConsumerProgress: vi.fn(),
- getConsumerSubscriptions: vi.fn(),
-}));
-
-// Mock matchMedia for antd responsive components
-beforeAll(() => {
- Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: vi.fn().mockImplementation((query: string) => ({
- matches: false,
- media: query,
- onchange: null,
- addListener: vi.fn(),
- removeListener: vi.fn(),
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- });
-});
-
-// Mock react-router-dom
-vi.mock('react-router-dom', () => ({
- useNavigate: () => vi.fn(),
- useParams: () => ({}),
-}));
-
-const makeGroup = (overrides: Partial<ConsumerGroup>): ConsumerGroup => ({
- name: 'order-consumer-group',
- namespace: 'default',
- clusterId: 'cluster-production',
- subscriptionMode: 'Push',
- consumeType: 'CLUSTERING',
- onlineInstances: 4,
- totalLag: 1280,
- subscribedTopics: ['ORDER_TOPIC'],
- subscriptionDataType: 'NORMAL',
- retryMaxTimes: 16,
- gmtCreate: '2025-03-15 10:30:00',
- gmtModified: '2025-03-15 10:30:00',
- delaySeconds: 12,
- instances: [],
- ...overrides,
-});
-
-const groups: ConsumerGroup[] = [
- makeGroup({ name: 'order-consumer-group' }),
- makeGroup({ name: 'payment-consumer-group', totalLag: 0, onlineInstances: 2
}),
-];
-
-const renderWithProviders = (ui: React.ReactElement) => {
- return render(
- <App>
- <LangProvider>{ui}</LangProvider>
- </App>,
- );
-};
-
-const createDeferred = <T,>() => {
- let resolve!: (value: T) => void;
- const promise = new Promise<T>((resolvePromise) => {
- resolve = resolvePromise;
- });
- return { promise, resolve };
-};
-
-describe('GroupManagement Page', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(consumerService.listConsumerGroups).mockResolvedValue(groups);
- vi.mocked(consumerService.getConsumerProgress).mockResolvedValue([]);
- vi.mocked(consumerService.getConsumerSubscriptions).mockResolvedValue([]);
- });
-
- afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
- });
-
- it('returns to the first page when the group search changes', async () => {
- vi.mocked(consumerService.listConsumerGroups).mockResolvedValue(
- Array.from({ length: 11 }, (_, index) =>
- makeGroup({ name: `group-${String(index).padStart(2, '0')}` }),
- ),
- );
- const user = userEvent.setup();
- const { container } = renderWithProviders(<GroupManagement />);
-
- await screen.findByText('group-00');
- await user.click(container.querySelector('.ant-pagination-next button')!);
- expect(await screen.findByText('group-10')).toBeInTheDocument();
- expect(screen.queryByText('group-00')).not.toBeInTheDocument();
-
- await user.type(screen.getByPlaceholderText('搜索消费组'), 'group-00');
- expect(await screen.findByText('group-00')).toBeInTheDocument();
- });
-
- it('clamps the current page when refreshed results have fewer pages', async
() => {
- const initialGroups = Array.from({ length: 21 }, (_, index) =>
- makeGroup({ name: `group-${String(index).padStart(2, '0')}` }),
- );
- vi.mocked(consumerService.listConsumerGroups)
- .mockResolvedValueOnce(initialGroups)
- .mockResolvedValueOnce(initialGroups.slice(0, 11));
- const user = userEvent.setup();
- const { container } = renderWithProviders(<GroupManagement />);
-
- await screen.findByText('group-00');
- await user.click(container.querySelector('.ant-pagination-item-3 a')!);
- expect(await screen.findByText('group-20')).toBeInTheDocument();
-
- await user.click(screen.getByRole('button', { name: /刷新/ }));
- expect(await screen.findByText('group-10')).toBeInTheDocument();
- expect(screen.queryByText('group-20')).not.toBeInTheDocument();
- });
-
- it('should render the page title', () => {
- renderWithProviders(<GroupManagement />);
- expect(screen.getByText('消费组管理')).toBeInTheDocument();
- });
-
- it('should render search input with placeholder', () => {
- renderWithProviders(<GroupManagement />);
- expect(screen.getByPlaceholderText('搜索消费组')).toBeInTheDocument();
- });
-
- it('should not render unsupported mutation actions in the global view',
async () => {
- renderWithProviders(<GroupManagement />);
- await waitFor(() => {
- expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
- });
- expect(screen.queryByText('创建消费组')).not.toBeInTheDocument();
- expect(screen.queryByText('配置')).not.toBeInTheDocument();
- expect(screen.queryByText('查看分布')).not.toBeInTheDocument();
- });
-
- it('should render refresh button', () => {
- renderWithProviders(<GroupManagement />);
- expect(screen.getByText('刷新')).toBeInTheDocument();
- });
-
- it('should display consumer group data from the service in table', async ()
=> {
- renderWithProviders(<GroupManagement />);
- await waitFor(() => {
- expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
- });
- expect(screen.getByText('payment-consumer-group')).toBeInTheDocument();
- });
-
- it('should render detail action buttons for each row', async () => {
- renderWithProviders(<GroupManagement />);
- await waitFor(() => {
- expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
- });
- const detailButtons = screen.getAllByText('详情');
- expect(detailButtons.length).toBeGreaterThan(0);
- });
-
- it('keeps the latest group detail when an earlier request resolves last',
async () => {
- const firstSubscriptions = createDeferred<SubscriptionEntry[]>();
- const firstProgress = createDeferred<QueueProgress[]>();
- const secondSubscriptions = createDeferred<SubscriptionEntry[]>();
- const secondProgress = createDeferred<QueueProgress[]>();
-
vi.mocked(consumerService.getConsumerSubscriptions).mockImplementation((groupName)
=>
- groupName === 'order-consumer-group'
- ? firstSubscriptions.promise
- : secondSubscriptions.promise,
- );
-
vi.mocked(consumerService.getConsumerProgress).mockImplementation((groupName) =>
- groupName === 'order-consumer-group' ? firstProgress.promise :
secondProgress.promise,
- );
-
- const user = userEvent.setup();
- renderWithProviders(<GroupManagement />);
- await screen.findByText('order-consumer-group');
-
- const detailButtons = screen.getAllByText('详情');
- await user.click(detailButtons[0]);
- await user.click(detailButtons[1]);
-
- await act(async () => {
- secondSubscriptions.resolve([
- {
- topic: 'SECOND_GROUP_TOPIC',
- expression: '*',
- type: 'TAG',
- filterMode: 'TAG',
- consistency: 'consistent',
- },
- ]);
- secondProgress.resolve([]);
- });
- expect(await screen.findByText('SECOND_GROUP_TOPIC')).toBeInTheDocument();
-
- await act(async () => {
- firstSubscriptions.resolve([
- {
- topic: 'FIRST_GROUP_TOPIC',
- expression: '*',
- type: 'TAG',
- filterMode: 'TAG',
- consistency: 'consistent',
- },
- ]);
- firstProgress.resolve([]);
- });
- expect(screen.getByText('SECOND_GROUP_TOPIC')).toBeInTheDocument();
- expect(screen.queryByText('FIRST_GROUP_TOPIC')).not.toBeInTheDocument();
- });
-
- it('keeps subscriptions when progress loading fails', async () => {
- vi.mocked(consumerService.getConsumerSubscriptions).mockResolvedValue([
- {
- topic: 'AVAILABLE_SUBSCRIPTION',
- expression: '*',
- type: 'TAG',
- filterMode: 'TAG',
- consistency: 'consistent',
- },
- ]);
- vi.mocked(consumerService.getConsumerProgress).mockRejectedValue(new
Error('unavailable'));
-
- const user = userEvent.setup();
- renderWithProviders(<GroupManagement />);
- await user.click(await screen.findByText('order-consumer-group'));
-
- expect(await
screen.findByText('AVAILABLE_SUBSCRIPTION')).toBeInTheDocument();
- const dialog = await screen.findByRole('dialog');
- await user.click(within(dialog).getAllByRole('tab')[2]);
- expect(document.querySelector('.ant-alert-error')).toBeInTheDocument();
- });
-
- it('keeps progress when subscription loading fails', async () => {
- vi.mocked(consumerService.getConsumerSubscriptions).mockRejectedValue(new
Error('unavailable'));
- vi.mocked(consumerService.getConsumerProgress).mockResolvedValue([
- {
- topic: 'orders',
- broker: 'broker-a',
- queueId: 0,
- brokerOffset: 20,
- consumerOffset: 10,
- diffTotal: 10,
- },
- ]);
-
- const user = userEvent.setup();
- renderWithProviders(<GroupManagement />);
- await user.click(await screen.findByText('order-consumer-group'));
- const dialog = await screen.findByRole('dialog');
- await user.click(within(dialog).getAllByRole('tab')[2]);
-
- expect(await screen.findByText('broker-a')).toBeInTheDocument();
- expect(document.querySelector('.ant-alert-error')).toBeInTheDocument();
- });
-
- it('queues one refresh instead of overlapping an active group request',
async () => {
- const initialGroups = createDeferred<ConsumerGroup[]>();
- const refreshedGroups = createDeferred<ConsumerGroup[]>();
- vi.mocked(consumerService.listConsumerGroups)
- .mockReturnValueOnce(initialGroups.promise)
- .mockReturnValueOnce(refreshedGroups.promise);
- renderWithProviders(<GroupManagement />);
-
- await waitFor(() =>
expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1));
- fireEvent.click(screen.getByText('刷新'));
- expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1);
-
- initialGroups.resolve([makeGroup({ name: 'initial-group' })]);
- await waitFor(() =>
expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(2));
-
- refreshedGroups.resolve([makeGroup({ name: 'fresh-group' })]);
- expect(await screen.findByText('fresh-group')).toBeInTheDocument();
- expect(screen.queryByText('initial-group')).not.toBeInTheDocument();
- });
-
- it('polls only while auto refresh is enabled and the document is visible',
async () => {
- const visibilityState = vi.spyOn(document, 'visibilityState',
'get').mockReturnValue('hidden');
- renderWithProviders(<GroupManagement />);
-
- await screen.findByText('order-consumer-group');
- expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1);
- vi.useFakeTimers();
-
- const autoRefreshSwitch = screen.getByRole('switch');
- fireEvent.click(autoRefreshSwitch);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2000);
- });
- expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1);
-
- visibilityState.mockReturnValue('visible');
- await act(async () => {
- document.dispatchEvent(new Event('visibilitychange'));
- });
- expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(2);
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2000);
- });
- expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(3);
-
- fireEvent.click(autoRefreshSwitch);
- await act(async () => {
- document.dispatchEvent(new Event('visibilitychange'));
- await vi.advanceTimersByTimeAsync(4000);
- });
- expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(3);
- });
-
- it('should filter groups by search text', async () => {
- const user = userEvent.setup();
- renderWithProviders(<GroupManagement />);
- await waitFor(() => {
- expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
- });
- const searchInput = screen.getByPlaceholderText('搜索消费组');
- await user.type(searchInput, 'ORDER');
- expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
-
expect(screen.queryByText('payment-consumer-group')).not.toBeInTheDocument();
- });
- it('scopes global group detail diagnostics to the record instance', async ()
=> {
- vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([
- makeGroup({ name: 'shared-group', instanceId: 'instance-2' }),
- ]);
- const user = userEvent.setup();
- renderWithProviders(<GroupManagement />);
- await screen.findByText('shared-group');
- await user.click(screen.getByText('详情'));
-
- await waitFor(() => {
- expect(consumerService.getConsumerSubscriptions).toHaveBeenCalledWith(
- 'shared-group',
- 'instance-2',
- );
- expect(consumerService.getConsumerProgress).toHaveBeenCalledWith(
- 'shared-group',
- 'instance-2',
- );
- });
- });
-
- it('shows a stopped status in details when no consumer instance is online',
async () => {
- vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([
- makeGroup({ name: 'offline-group', onlineInstances: 0 }),
- ]);
- const user = userEvent.setup();
- renderWithProviders(<GroupManagement />);
- await screen.findByText('offline-group');
- await user.click(screen.getByText('详情'));
-
- const dialog = await screen.findByRole('dialog');
- expect(within(dialog).getByText('已停止')).toBeInTheDocument();
- expect(within(dialog).queryByText('在线')).not.toBeInTheDocument();
- });
-
- it('uses unique row keys for same-named groups from different instances',
async () => {
- vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([
- makeGroup({ name: 'shared-group', instanceId: 'instance-1' }),
- makeGroup({ name: 'shared-group', instanceId: 'instance-2' }),
- ]);
- const { container } = renderWithProviders(<GroupManagement />);
- await screen.findAllByText('shared-group');
-
- const rowKeys = Array.from(container.querySelectorAll('tbody
tr[data-row-key]')).map((row) =>
- row.getAttribute('data-row-key'),
- );
- expect(rowKeys).toContain('instance-1\0shared-group');
- expect(rowKeys).toContain('instance-2\0shared-group');
- expect(new Set(rowKeys).size).toBe(rowKeys.length);
- });
-});