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

Reply via email to