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 b8f33777 fix: report partial broker configuration updates (#1001)
b8f33777 is described below

commit b8f3377774bec72895d0fccb38a5de1c205c1083
Author: 0 <[email protected]>
AuthorDate: Wed Aug 5 17:43:10 2026 +0800

    fix: report partial broker configuration updates (#1001)
    
    Co-authored-by: btlqql <[email protected]>
---
 .../studio/cluster/broker/ClusterController.java   |  3 +-
 .../studio/cluster/broker/ClusterService.java      | 78 ++++++++++++++++++----
 .../config/BrokerConfigUpdateFailureVO.java        | 27 ++++++++
 .../config/ClusterConfigUpdateResultVO.java        | 38 +++++++++++
 .../cluster/broker/ClusterControllerTest.java      | 31 +++++++--
 .../studio/cluster/broker/ClusterServiceTest.java  | 68 +++++++++++++++----
 web/src/api/cluster.test.ts                        | 15 +++++
 web/src/api/cluster.ts                             | 20 +++++-
 web/src/i18n/translations.ts                       |  8 +++
 .../pages/cluster/__tests__/ClusterPage.test.tsx   | 10 ++-
 web/src/pages/cluster/index.tsx                    | 23 +++++--
 web/src/services/clusterService.ts                 | 22 ++++--
 12 files changed, 300 insertions(+), 43 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterController.java
index 36bd36d1..7192c6c2 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterController.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.cluster.broker;
 
+import org.apache.rocketmq.studio.cluster.config.ClusterConfigUpdateResultVO;
 import org.apache.rocketmq.studio.cluster.config.UpdateConfigDTO;
 
 import org.apache.rocketmq.studio.common.domain.Result;
@@ -56,7 +57,7 @@ public class ClusterController {
     }
 
     @PostMapping("/config/update")
-    public Result<ClusterVO> updateClusterConfig(@Valid @RequestBody(required 
= false) UpdateConfigDTO command) {
+    public Result<ClusterConfigUpdateResultVO> updateClusterConfig(@Valid 
@RequestBody(required = false) UpdateConfigDTO command) {
         requireUpdateConfigCommand(command);
         return Result.ok(clusterService.updateClusterConfig(command));
     }
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 e57b5cf8..16a8e155 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
@@ -16,6 +16,8 @@
  */
 package org.apache.rocketmq.studio.cluster.broker;
 
+import org.apache.rocketmq.studio.cluster.config.BrokerConfigUpdateFailureVO;
+import org.apache.rocketmq.studio.cluster.config.ClusterConfigUpdateResultVO;
 import org.apache.rocketmq.studio.cluster.config.ClusterConfigVO;
 import org.apache.rocketmq.studio.cluster.config.UpdateConfigDTO;
 import org.apache.rocketmq.studio.cluster.nameserver.CreateNameServerDTO;
@@ -28,11 +30,13 @@ import 
org.apache.rocketmq.studio.cluster.proxy.RestartProxyDTO;
 
 import org.apache.rocketmq.studio.common.domain.enums.FlushDiskType;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.ops.audit.AuditService;
 import org.apache.rocketmq.studio.rocketmq.RocketMQBrokerConfigService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Properties;
 
@@ -44,6 +48,7 @@ public class ClusterService {
     private final ClusterRepository clusterRepository;
     private final ClusterProvider clusterProvider;
     private final RocketMQBrokerConfigService brokerConfigService;
+    private final AuditService auditService;
 
     public List<ClusterVO> listClusters() {
         log.info("Listing all clusters");
@@ -89,13 +94,51 @@ public class ClusterService {
         }
     }
 
-    public ClusterVO updateClusterConfig(UpdateConfigDTO command) {
+    public ClusterConfigUpdateResultVO updateClusterConfig(UpdateConfigDTO 
command) {
         log.info("Updating cluster config for: {}", command.getId());
         requireMatchingDefaultQueueNums(command);
         ClusterVO cluster = resolveCluster(command.getId());
 
         ClusterConfigVO config = copyConfig(cluster.getConfig());
+        applyConfig(command, config);
 
+        List<String> successfulBrokers = new ArrayList<>();
+        List<BrokerConfigUpdateFailureVO> failedBrokers = new ArrayList<>();
+        if (cluster.getBrokers() != null && !cluster.getBrokers().isEmpty()) {
+            Properties brokerProps = buildBrokerProperties(command);
+            for (BrokerVO broker : cluster.getBrokers()) {
+                String address = broker.getAddr();
+                if (address == null || address.isEmpty()) {
+                    continue;
+                }
+                try {
+                    brokerConfigService.updateBrokerConfig(address, 
command.getId(), brokerProps);
+                    successfulBrokers.add(address);
+                } catch (Exception e) {
+                    failedBrokers.add(BrokerConfigUpdateFailureVO.builder()
+                            .address(address)
+                            .message(e.getMessage())
+                            .build());
+                }
+            }
+        }
+
+        ClusterConfigUpdateResultVO.Status status = 
updateStatus(successfulBrokers, failedBrokers);
+        if (failedBrokers.isEmpty()) {
+            clusterRepository.updateConfig(command.getId(), config);
+            cluster.setConfig(config);
+        }
+        recordConfigUpdateAudit(command.getId(), status, successfulBrokers, 
failedBrokers);
+        log.info("Cluster config update finished for {} with status {}", 
command.getId(), status);
+        return ClusterConfigUpdateResultVO.builder()
+                .cluster(cluster)
+                .status(status)
+                .successfulBrokers(List.copyOf(successfulBrokers))
+                .failedBrokers(List.copyOf(failedBrokers))
+                .build();
+    }
+
+    private void applyConfig(UpdateConfigDTO command, ClusterConfigVO config) {
         if (command.getFlushDiskType() != null) {
             
config.setFlushDiskType(parseFlushDiskType(command.getFlushDiskType()));
         }
@@ -120,21 +163,30 @@ public class ClusterService {
         if (command.getBrokerPermission() != null) {
             config.setBrokerPermission(command.getBrokerPermission());
         }
+    }
 
-        // Push config to live brokers via admin API
-        if (cluster.getBrokers() != null && !cluster.getBrokers().isEmpty()) {
-            Properties brokerProps = buildBrokerProperties(command);
-            for (BrokerVO broker : cluster.getBrokers()) {
-                if (broker.getAddr() != null && !broker.getAddr().isEmpty()) {
-                    brokerConfigService.updateBrokerConfig(broker.getAddr(), 
command.getId(), brokerProps);
-                }
-            }
+    private ClusterConfigUpdateResultVO.Status updateStatus(
+            List<String> successfulBrokers,
+            List<BrokerConfigUpdateFailureVO> failedBrokers) {
+        if (failedBrokers.isEmpty()) {
+            return ClusterConfigUpdateResultVO.Status.SUCCESS;
         }
+        if (successfulBrokers.isEmpty()) {
+            return ClusterConfigUpdateResultVO.Status.FAILED;
+        }
+        return ClusterConfigUpdateResultVO.Status.PARTIAL;
+    }
 
-        clusterRepository.updateConfig(command.getId(), config);
-        cluster.setConfig(config);
-        log.info("Cluster config updated successfully for: {}", 
command.getId());
-        return cluster;
+    private void recordConfigUpdateAudit(
+            String clusterId,
+            ClusterConfigUpdateResultVO.Status status,
+            List<String> successfulBrokers,
+            List<BrokerConfigUpdateFailureVO> failedBrokers) {
+        String detail = "successfulBrokers=" + successfulBrokers
+                + ", failedBrokers=" + failedBrokers.stream()
+                .map(failure -> failure.getAddress() + ": " + 
failure.getMessage())
+                .toList();
+        auditService.record("UPDATE_CLUSTER_CONFIG", "CLUSTER:" + clusterId, 
detail, status.name());
     }
 
     private ClusterVO resolveCluster(String clusterId) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/config/BrokerConfigUpdateFailureVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/config/BrokerConfigUpdateFailureVO.java
new file mode 100644
index 00000000..5b9d62a6
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/config/BrokerConfigUpdateFailureVO.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.config;
+
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class BrokerConfigUpdateFailureVO {
+    String address;
+    String message;
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/config/ClusterConfigUpdateResultVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/config/ClusterConfigUpdateResultVO.java
new file mode 100644
index 00000000..4fabf6b7
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/config/ClusterConfigUpdateResultVO.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.config;
+
+import org.apache.rocketmq.studio.cluster.broker.ClusterVO;
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.List;
+
+@Value
+@Builder
+public class ClusterConfigUpdateResultVO {
+    ClusterVO cluster;
+    Status status;
+    List<String> successfulBrokers;
+    List<BrokerConfigUpdateFailureVO> failedBrokers;
+
+    public enum Status {
+        SUCCESS,
+        PARTIAL,
+        FAILED
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterControllerTest.java
index 04c438df..b71527b5 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterControllerTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.cluster.broker;
 
+import org.apache.rocketmq.studio.cluster.config.ClusterConfigUpdateResultVO;
 import org.apache.rocketmq.studio.cluster.config.ClusterConfigVO;
 import org.apache.rocketmq.studio.cluster.config.UpdateConfigDTO;
 
@@ -167,7 +168,13 @@ class ClusterControllerTest {
                 .writeQueueNums(16)
                 .readQueueNums(16)
                 .build());
-        
when(clusterService.updateClusterConfig(any(UpdateConfigDTO.class))).thenReturn(updated);
+        
when(clusterService.updateClusterConfig(any(UpdateConfigDTO.class))).thenReturn(
+                ClusterConfigUpdateResultVO.builder()
+                        .cluster(updated)
+                        .status(ClusterConfigUpdateResultVO.Status.SUCCESS)
+                        .successfulBrokers(Collections.emptyList())
+                        .failedBrokers(Collections.emptyList())
+                        .build());
 
         UpdateConfigDTO command = UpdateConfigDTO.builder()
                 .id("cluster-1")
@@ -181,10 +188,11 @@ class ClusterControllerTest {
                         .content(objectMapper.writeValueAsString(command)))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data.id").value("cluster-1"))
-                
.andExpect(jsonPath("$.data.config.flushDiskType").value("SYNC_FLUSH"))
-                .andExpect(jsonPath("$.data.config.writeQueueNums").value(16))
-                .andExpect(jsonPath("$.data.config.readQueueNums").value(16));
+                .andExpect(jsonPath("$.data.status").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.cluster.id").value("cluster-1"))
+                
.andExpect(jsonPath("$.data.cluster.config.flushDiskType").value("SYNC_FLUSH"))
+                
.andExpect(jsonPath("$.data.cluster.config.writeQueueNums").value(16))
+                
.andExpect(jsonPath("$.data.cluster.config.readQueueNums").value(16));
     }
 
     @Test
@@ -255,7 +263,7 @@ class ClusterControllerTest {
     @MethodSource("boundaryConfigValues")
     void updateConfigShouldAcceptBoundaryValues(String field, int value) 
throws Exception {
         when(clusterService.updateClusterConfig(any(UpdateConfigDTO.class)))
-                .thenReturn(buildCluster("cluster-1", "production-cluster", 
ClusterStatus.healthy));
+                .thenReturn(successfulUpdateResult());
         ObjectNode command = objectMapper.createObjectNode()
                 .put("id", "cluster-1")
                 .put(field, value);
@@ -272,7 +280,7 @@ class ClusterControllerTest {
     @Test
     void updateConfigShouldAcceptIdOnlyPartialRequest() throws Exception {
         when(clusterService.updateClusterConfig(any(UpdateConfigDTO.class)))
-                .thenReturn(buildCluster("cluster-1", "production-cluster", 
ClusterStatus.healthy));
+                .thenReturn(successfulUpdateResult());
         ObjectNode command = objectMapper.createObjectNode().put("id", 
"cluster-1");
 
         mockMvc.perform(post("/api/clusters/config/update")
@@ -295,6 +303,15 @@ class ClusterControllerTest {
                 .andExpect(jsonPath("$.data.message").value("Broker restart 
initiated for broker-0"));
     }
 
+    private ClusterConfigUpdateResultVO successfulUpdateResult() {
+        return ClusterConfigUpdateResultVO.builder()
+                .cluster(buildCluster("cluster-1", "production-cluster", 
ClusterStatus.healthy))
+                .status(ClusterConfigUpdateResultVO.Status.SUCCESS)
+                .successfulBrokers(Collections.emptyList())
+                .failedBrokers(Collections.emptyList())
+                .build();
+    }
+
     private ClusterVO buildCluster(String id, String name, ClusterStatus 
status) {
         ClusterVO cluster = ClusterVO.builder()
                 .name(name)
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 f980f8f0..f9ecfe66 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
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.cluster.broker;
 
+import org.apache.rocketmq.studio.cluster.config.ClusterConfigUpdateResultVO;
 import org.apache.rocketmq.studio.cluster.config.ClusterConfigVO;
 import org.apache.rocketmq.studio.cluster.config.UpdateConfigDTO;
 import org.apache.rocketmq.studio.cluster.nameserver.CreateNameServerDTO;
@@ -31,6 +32,8 @@ import 
org.apache.rocketmq.studio.common.domain.enums.ClusterStatus;
 import org.apache.rocketmq.studio.common.domain.enums.ClusterType;
 import org.apache.rocketmq.studio.common.domain.enums.FlushDiskType;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.ops.audit.AuditService;
+import org.apache.rocketmq.studio.rocketmq.RocketMQBrokerConfigService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -48,6 +51,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -63,6 +67,12 @@ class ClusterServiceTest {
     @Mock
     private ClusterProvider clusterProvider;
 
+    @Mock
+    private RocketMQBrokerConfigService brokerConfigService;
+
+    @Mock
+    private AuditService auditService;
+
     @InjectMocks
     private ClusterService clusterService;
 
@@ -178,9 +188,9 @@ class ClusterServiceTest {
                 .flushDiskType("SYNC_FLUSH")
                 .build();
 
-        ClusterVO result = clusterService.updateClusterConfig(command);
+        ClusterConfigUpdateResultVO result = 
clusterService.updateClusterConfig(command);
 
-        
assertThat(result.getConfig().getFlushDiskType()).isEqualTo(FlushDiskType.SYNC_FLUSH);
+        
assertThat(result.getCluster().getConfig().getFlushDiskType()).isEqualTo(FlushDiskType.SYNC_FLUSH);
         verify(clusterRepository).updateConfig(eq("cluster-1"), 
any(ClusterConfigVO.class));
     }
 
@@ -200,9 +210,9 @@ class ClusterServiceTest {
                 .brokerPermission(4)
                 .build();
 
-        ClusterVO result = clusterService.updateClusterConfig(command);
+        ClusterConfigUpdateResultVO result = 
clusterService.updateClusterConfig(command);
 
-        ClusterConfigVO config = result.getConfig();
+        ClusterConfigVO config = result.getCluster().getConfig();
         
assertThat(config.getFlushDiskType()).isEqualTo(FlushDiskType.SYNC_FLUSH);
         assertThat(config.isAutoCreateTopicEnable()).isFalse();
         assertThat(config.isAutoCreateSubscriptionGroup()).isFalse();
@@ -223,9 +233,9 @@ class ClusterServiceTest {
                 .flushDiskType("SYNC_FLUSH")
                 .build();
 
-        ClusterVO result = clusterService.updateClusterConfig(command);
+        ClusterConfigUpdateResultVO result = 
clusterService.updateClusterConfig(command);
 
-        ClusterConfigVO config = result.getConfig();
+        ClusterConfigVO config = result.getCluster().getConfig();
         assertThat(config).isNotSameAs(storedConfig);
         
assertThat(config.getFlushDiskType()).isEqualTo(FlushDiskType.SYNC_FLUSH);
         assertThat(config.getWriteQueueNums()).isEqualTo(8);
@@ -240,6 +250,39 @@ class ClusterServiceTest {
         
assertThat(storedConfig.getFlushDiskType()).isEqualTo(FlushDiskType.ASYNC_FLUSH);
     }
 
+    @Test
+    void updateConfigShouldReportPartialFailureAfterOneBrokerSucceeds() {
+        sampleCluster.setBrokers(List.of(
+                
BrokerVO.builder().name("broker-0").addr("10.0.0.1:10911").build(),
+                
BrokerVO.builder().name("broker-1").addr("10.0.0.2:10911").build()));
+        
when(clusterRepository.findById("cluster-1")).thenReturn(Optional.of(sampleCluster));
+        doNothing().when(brokerConfigService).updateBrokerConfig(
+                eq("10.0.0.1:10911"), eq("cluster-1"), any());
+        doThrow(new BusinessException(500, "broker unavailable"))
+                .when(brokerConfigService).updateBrokerConfig(
+                        eq("10.0.0.2:10911"), eq("cluster-1"), any());
+
+        ClusterConfigUpdateResultVO result = 
clusterService.updateClusterConfig(
+                
UpdateConfigDTO.builder().id("cluster-1").writeQueueNums(16).build());
+
+        
assertThat(result.getStatus()).isEqualTo(ClusterConfigUpdateResultVO.Status.PARTIAL);
+        
assertThat(result.getSuccessfulBrokers()).containsExactly("10.0.0.1:10911");
+        
assertThat(result.getFailedBrokers()).singleElement().satisfies(failure -> {
+            assertThat(failure.getAddress()).isEqualTo("10.0.0.2:10911");
+            assertThat(failure.getMessage()).contains("broker unavailable");
+        });
+        verify(brokerConfigService).updateBrokerConfig(
+                eq("10.0.0.1:10911"), eq("cluster-1"), any());
+        verify(brokerConfigService).updateBrokerConfig(
+                eq("10.0.0.2:10911"), eq("cluster-1"), any());
+        verify(clusterRepository, never()).updateConfig(eq("cluster-1"), 
any());
+        verify(auditService).record(
+                eq("UPDATE_CLUSTER_CONFIG"),
+                eq("CLUSTER:cluster-1"),
+                org.mockito.ArgumentMatchers.contains("10.0.0.2:10911"),
+                eq("PARTIAL"));
+    }
+
     @Test
     void updateConfigShouldThrowWhenClusterNotFound() {
         
when(clusterRepository.findById("missing")).thenReturn(Optional.empty());
@@ -286,10 +329,10 @@ class ClusterServiceTest {
                 .flushDiskType("ASYNC_FLUSH")
                 .build();
 
-        ClusterVO result = clusterService.updateClusterConfig(command);
+        ClusterConfigUpdateResultVO result = 
clusterService.updateClusterConfig(command);
 
-        assertThat(result.getConfig()).isNotNull();
-        
assertThat(result.getConfig().getFlushDiskType()).isEqualTo(FlushDiskType.ASYNC_FLUSH);
+        assertThat(result.getCluster().getConfig()).isNotNull();
+        
assertThat(result.getCluster().getConfig().getFlushDiskType()).isEqualTo(FlushDiskType.ASYNC_FLUSH);
     }
 
     @Test
@@ -523,10 +566,11 @@ class ClusterServiceTest {
                 .maxMessageSize(8_388_608)
                 .build();
 
-        ClusterVO result = clusterService.updateClusterConfig(command);
+        ClusterConfigUpdateResultVO result = 
clusterService.updateClusterConfig(command);
 
-        
assertThat(result.getConfig().getMaxMessageSize()).isEqualTo(8_388_608);
-        verify(clusterRepository).updateConfig("cluster-1", 
result.getConfig());
+        
assertThat(result.getCluster().getConfig().getMaxMessageSize()).isEqualTo(8_388_608);
+        
assertThat(result.getStatus()).isEqualTo(ClusterConfigUpdateResultVO.Status.SUCCESS);
+        verify(clusterRepository).updateConfig("cluster-1", 
result.getCluster().getConfig());
     }
 
     private void assertUnsupportedOperation(ThrowableAssert.ThrowingCallable 
callable, String message) {
diff --git a/web/src/api/cluster.test.ts b/web/src/api/cluster.test.ts
index 65eea44e..c77a6645 100644
--- a/web/src/api/cluster.test.ts
+++ b/web/src/api/cluster.test.ts
@@ -29,6 +29,7 @@ import {
   restartBroker,
   restartNameServer,
   restartProxy,
+  updateClusterConfig,
   updateK8sCert,
   updateNameServer,
   upgradeNameServer,
@@ -132,6 +133,20 @@ describe('K8s certificate API', () => {
     });
   });
 
+  it('returns per-broker cluster config update results', async () => {
+    const result = {
+      cluster: { id: 'cluster-1' },
+      status: 'PARTIAL',
+      successfulBrokers: ['10.0.0.1:10911'],
+      failedBrokers: [{ address: '10.0.0.2:10911', message: 'broker 
unavailable' }],
+    };
+    mock.onPost('/clusters/config/update').reply(200, { code: 200, data: 
result });
+
+    await expect(updateClusterConfig({ id: 'cluster-1', writeQueueNums: 16 
})).resolves.toEqual(
+      result,
+    );
+  });
+
   it('sends NameServer operation payloads to their endpoints', async () => {
     const target = { clusterId: 'cluster-1', addr: '127.0.0.1:9876' };
     const requests = [
diff --git a/web/src/api/cluster.ts b/web/src/api/cluster.ts
index cb8f4ea4..fc0e4a0c 100644
--- a/web/src/api/cluster.ts
+++ b/web/src/api/cluster.ts
@@ -73,6 +73,20 @@ export interface ClusterConfig {
   deleteWhen: string;
 }
 
+export type ClusterConfigUpdateStatus = 'SUCCESS' | 'PARTIAL' | 'FAILED';
+
+export interface BrokerConfigUpdateFailure {
+  address: string;
+  message: string;
+}
+
+export interface ClusterConfigUpdateResult {
+  cluster: ClusterInfo;
+  status: ClusterConfigUpdateStatus;
+  successfulBrokers: string[];
+  failedBrokers: BrokerConfigUpdateFailure[];
+}
+
 export interface ClusterProbeResult {
   connected: boolean;
   namesrvAddr: string;
@@ -116,7 +130,11 @@ export async function getCluster(id: string) {
 }
 
 export async function updateClusterConfig(data: { id: string } & 
Partial<ClusterConfig>) {
-  await client.post('/clusters/config/update', data);
+  const res = await client.post<{ data: ClusterConfigUpdateResult }>(
+    '/clusters/config/update',
+    data,
+  );
+  return res.data.data;
 }
 
 export async function restartBroker(clusterId: string, brokerName: string) {
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index a6081e62..d15ccb37 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -546,6 +546,14 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'cluster.probeElapsed': { zh: '耗时 (ms)', en: 'Elapsed (ms)' },
   'cluster.configTitle': { zh: '配置 - {name}', en: 'Config - {name}' },
   'cluster.configUpdated': { zh: '配置已更新', en: 'Configuration updated' },
+  'cluster.configPartiallyUpdated': {
+    zh: '部分 Broker 配置已更新,请检查:{brokers}',
+    en: 'Some broker configurations were updated. Check: {brokers}',
+  },
+  'cluster.configUpdateFailed': {
+    zh: 'Broker 配置更新失败,请检查:{brokers}',
+    en: 'Broker configuration update failed. Check: {brokers}',
+  },
   'cluster.flushDiskType': { zh: '刷盘方式', en: 'Flush Disk Type' },
   'cluster.syncFlush': { zh: '同步刷盘', en: 'Sync Flush' },
   'cluster.asyncFlush': { zh: '异步刷盘', en: 'Async Flush' },
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index b436e577..ca117fd8 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -136,7 +136,15 @@ describe('Cluster page', () => {
     
clusterServiceMocks.listClusters.mockReset().mockResolvedValue([buildCluster()]);
     clusterServiceMocks.restartProxy.mockReset().mockResolvedValue(undefined);
     clusterServiceMocks.testClusterConnection.mockReset();
-    
clusterServiceMocks.updateClusterConfig.mockReset().mockResolvedValue(undefined);
+    
clusterServiceMocks.updateClusterConfig.mockReset().mockImplementation(async () 
=> {
+      const cluster = buildCluster();
+      return {
+        cluster,
+        status: 'SUCCESS',
+        successfulBrokers: cluster.brokers.map((broker) => broker.addr),
+        failedBrokers: [],
+      };
+    });
     
clusterServiceMocks.updateNameServer.mockReset().mockResolvedValue(undefined);
   });
 
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index f285d2ee..cab68134 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -526,13 +526,28 @@ const ClusterPage = () => {
                   ...configValues,
                   maxMessageSize: maxMessageSizeMB * 1048576,
                 };
-                await updateClusterConfig({
+                const result = await updateClusterConfig({
                   id: selectedCluster.id,
                   ...nextConfig,
                 });
-                await requestRefresh('operation');
-                message.success(t('cluster.configUpdated'));
-                setConfigModalOpen(false);
+                if (result.status === 'SUCCESS') {
+                  await requestRefresh('operation');
+                  message.success(t('cluster.configUpdated'));
+                  setConfigModalOpen(false);
+                  return;
+                }
+
+                const failedAddresses = result.failedBrokers
+                  .map((failure) => failure.address)
+                  .join(', ');
+                if (result.status === 'PARTIAL') {
+                  await requestRefresh('operation');
+                  message.warning(
+                    t('cluster.configPartiallyUpdated', { brokers: 
failedAddresses }),
+                  );
+                  return;
+                }
+                message.error(t('cluster.configUpdateFailed', { brokers: 
failedAddresses }));
               });
             }}
             width={560}
diff --git a/web/src/services/clusterService.ts 
b/web/src/services/clusterService.ts
index 601f9f22..6d83bcdc 100644
--- a/web/src/services/clusterService.ts
+++ b/web/src/services/clusterService.ts
@@ -1,6 +1,12 @@
 import { isMockMode } from './dataMode';
 import * as clusterApi from '../api/cluster';
-import type { ClusterConfig, ClusterInfo, ClusterProbeResult, K8sCertInfo } 
from '../api/cluster';
+import type {
+  ClusterConfig,
+  ClusterConfigUpdateResult,
+  ClusterInfo,
+  ClusterProbeResult,
+  K8sCertInfo,
+} from '../api/cluster';
 import clusters, { mockK8sCerts } from '../mock/clusters';
 
 const mockCertStore: K8sCertInfo[] = mockK8sCerts.map((cert) => ({
@@ -131,8 +137,14 @@ export async function deleteK8sCert(id: string): 
Promise<void> {
 export async function updateClusterConfig(data: { id: string } & 
Partial<ClusterConfig>) {
   if (isMockMode()) {
     const { id, ...config } = data;
-    Object.assign(getMockCluster(id).config, config);
-    return;
+    const cluster = getMockCluster(id);
+    Object.assign(cluster.config, config);
+    return {
+      cluster: copyCluster(cluster),
+      status: 'SUCCESS',
+      successfulBrokers: cluster.brokers.map((broker) => broker.addr),
+      failedBrokers: [],
+    } satisfies ClusterConfigUpdateResult;
   }
   return clusterApi.updateClusterConfig(data);
 }
@@ -208,7 +220,9 @@ export async function updateNameServer(data: {
     const nameServer = nameServers.find((item) => item.addr === data.addr);
     if (!nameServer) throw new Error(`NameServer not found: ${data.addr}`);
     if (data.newAddr && data.newAddr !== data.addr) {
-      const duplicate = nameServers.some((item) => item !== nameServer && 
item.addr === data.newAddr);
+      const duplicate = nameServers.some(
+        (item) => item !== nameServer && item.addr === data.newAddr,
+      );
       if (duplicate) throw new Error(`NameServer already exists: 
${data.newAddr}`);
     }
     if (data.newAddr) nameServer.addr = data.newAddr;

Reply via email to