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 aca44d99c feat(audit): record control-plane metadata and proxy 
operations (#2645)
aca44d99c is described below

commit aca44d99ccb610808830eb425589935c30307747
Author: coder999o <[email protected]>
AuthorDate: Mon Aug 31 20:49:45 2026 +0800

    feat(audit): record control-plane metadata and proxy operations (#2645)
---
 .../studio/audit/OperationAuditConstants.java      |  61 +++++
 .../studio/cluster/proxy/ProxyAddressService.java  |  63 ++++-
 .../studio/instance/topic/MetadataService.java     | 109 +++++++-
 .../cluster/proxy/ProxyAddressServiceTest.java     |  25 +-
 .../studio/instance/topic/MetadataServiceTest.java | 105 ++++++++
 web/src/i18n/translations.ts                       |  69 +++++
 web/src/mock/audit.ts                              |  84 ++++++
 web/src/pages/ops/__tests__/AuditPage.test.tsx     |  51 +++-
 .../pages/ops/__tests__/auditPresentation.test.ts  | 148 +++++++++++
 web/src/pages/ops/audit.tsx                        | 120 +++++++--
 web/src/pages/ops/auditPresentation.ts             | 292 +++++++++++++++++++++
 11 files changed, 1074 insertions(+), 53 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditConstants.java
 
b/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditConstants.java
new file mode 100644
index 000000000..88549d7b6
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/audit/OperationAuditConstants.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.audit;
+
+/**
+ * Operation audit vocabulary shared by Studio services.
+ */
+public final class OperationAuditConstants {
+
+    private OperationAuditConstants() {
+    }
+
+    public static final class Operation {
+        public static final String CREATE_TOPIC = "CREATE_TOPIC";
+        public static final String UPDATE_TOPIC = "UPDATE_TOPIC";
+        public static final String DELETE_TOPIC = "DELETE_TOPIC";
+
+        public static final String CREATE_GROUP = "CREATE_GROUP";
+        public static final String UPDATE_GROUP = "UPDATE_GROUP";
+        public static final String DELETE_GROUP = "DELETE_GROUP";
+        public static final String RESET_OFFSET = "RESET_OFFSET";
+
+        public static final String ADD_PROXY_ADDRESS = "ADD_PROXY_ADDRESS";
+        public static final String REMOVE_PROXY_ADDRESS = 
"REMOVE_PROXY_ADDRESS";
+        public static final String RELOAD_PROXY_CONFIG = "RELOAD_PROXY_CONFIG";
+
+        private Operation() {
+        }
+    }
+
+    public static final class ResourceType {
+        public static final String TOPIC = "TOPIC";
+        public static final String GROUP = "GROUP";
+        public static final String PROXY = "PROXY";
+
+        private ResourceType() {
+        }
+    }
+
+    public static final class Result {
+        public static final String SUCCESS = "SUCCESS";
+        public static final String FAILED = "FAILED";
+
+        private Result() {
+        }
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
index 145f67355..16a0a1f2b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressService.java
@@ -18,6 +18,10 @@
 package org.apache.rocketmq.studio.cluster.proxy;
 
 import org.apache.commons.validator.routines.InetAddressValidator;
+import org.apache.rocketmq.studio.audit.OperationAuditConstants.Operation;
+import org.apache.rocketmq.studio.audit.OperationAuditConstants.ResourceType;
+import org.apache.rocketmq.studio.audit.OperationAuditConstants.Result;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.cluster.broker.ClusterService;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import 
org.apache.rocketmq.studio.common.util.NoRedirectClientHttpRequestFactory;
@@ -82,28 +86,44 @@ public class ProxyAddressService {
     private final ProxyHealthProbe healthProbe;
     private final ExecutorService probeExecutor;
     private final long topologyTotalTimeoutMillis;
+    private final OperationAuditService operationAuditService;
 
     @Autowired
-    public ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe) {
-        this(clusterService, healthProbe, newRestTemplate(), 
defaultProbeExecutor(), TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
+    public ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe,
+                               OperationAuditService operationAuditService) {
+        this(clusterService, healthProbe, operationAuditService, 
newRestTemplate(), defaultProbeExecutor(),
+                TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
+    }
+
+    ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe) {
+        this(clusterService, healthProbe, null, newRestTemplate(), 
defaultProbeExecutor(),
+                TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
     }
 
     ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe, RestTemplate restTemplate) {
-        this(clusterService, healthProbe, restTemplate, 
defaultProbeExecutor(), TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
+        this(clusterService, healthProbe, null, restTemplate, 
defaultProbeExecutor(), TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
+    }
+
+    ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe, RestTemplate restTemplate,
+                        OperationAuditService operationAuditService) {
+        this(clusterService, healthProbe, operationAuditService, restTemplate, 
defaultProbeExecutor(),
+                TOPOLOGY_TOTAL_TIMEOUT_MILLIS);
     }
 
     ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe,
                         ExecutorService probeExecutor, long 
topologyTotalTimeoutMillis) {
-        this(clusterService, healthProbe, newRestTemplate(), probeExecutor, 
topologyTotalTimeoutMillis);
+        this(clusterService, healthProbe, null, newRestTemplate(), 
probeExecutor, topologyTotalTimeoutMillis);
     }
 
-    ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe, RestTemplate restTemplate,
+    ProxyAddressService(ClusterService clusterService, ProxyHealthProbe 
healthProbe,
+                        OperationAuditService operationAuditService, 
RestTemplate restTemplate,
                         ExecutorService probeExecutor, long 
topologyTotalTimeoutMillis) {
         this.clusterService = clusterService;
         this.healthProbe = healthProbe;
         this.restTemplate = restTemplate;
         this.probeExecutor = probeExecutor;
         this.topologyTotalTimeoutMillis = topologyTotalTimeoutMillis;
+        this.operationAuditService = operationAuditService;
     }
 
     private static RestTemplate newRestTemplate() {
@@ -249,21 +269,27 @@ public class ProxyAddressService {
 
     public synchronized void addProxyAddr(String newProxyAddr) {
         String normalized = normalizeProxyAddr(newProxyAddr, "newProxyAddr");
-        proxyAddrs.add(normalized);
+        boolean added = proxyAddrs.add(normalized);
         if (currentProxyAddr == null || currentProxyAddr.isBlank()) {
             currentProxyAddr = normalized;
         }
+        if (added) {
+            recordAudit(Operation.ADD_PROXY_ADDRESS, ResourceType.PROXY, 
normalized, null);
+        }
         log.info("Added Proxy address {}", normalized);
     }
 
     public synchronized void removeProxyAddr(String proxyAddr) {
         String normalized = normalizeProxyAddr(proxyAddr, "proxyAddr");
         if (!proxyAddrs.remove(normalized)) {
+            recordAudit(Operation.REMOVE_PROXY_ADDRESS, ResourceType.PROXY, 
normalized, null,
+                    Result.FAILED, "Proxy address not found");
             throw new BusinessException(404, "Proxy address not found: " + 
normalized);
         }
         if (normalized.equals(currentProxyAddr)) {
             currentProxyAddr = proxyAddrs.stream().findFirst().orElse("");
         }
+        recordAudit(Operation.REMOVE_PROXY_ADDRESS, ResourceType.PROXY, 
normalized, null);
         log.info("Removed Proxy address {}", normalized);
     }
 
@@ -283,16 +309,23 @@ public class ProxyAddressService {
             if (!status.is2xxSuccessful()) {
                 throw new BusinessException(502, "Proxy returned " + status);
             }
+            recordAudit(Operation.RELOAD_PROXY_CONFIG, ResourceType.PROXY, 
normalized, normalizedClusterId);
             log.info("Proxy {} accepted config reload", normalized);
         } catch (HttpStatusCodeException ex) {
+            recordAudit(Operation.RELOAD_PROXY_CONFIG, ResourceType.PROXY, 
normalized, normalizedClusterId,
+                    Result.FAILED, "Proxy returned " + ex.getStatusCode());
             throw new BusinessException(502, "Proxy returned " + 
ex.getStatusCode());
         } catch (ResourceAccessException ex) {
             log.warn("Unable to reach proxy {} for config reload: {}", 
normalized, ex.getMessage());
+            recordAudit(Operation.RELOAD_PROXY_CONFIG, ResourceType.PROXY, 
normalized, normalizedClusterId,
+                    Result.FAILED, "Unable to reach proxy");
             throw new BusinessException(502, "Unable to reach proxy: " + 
ex.getMessage());
         } catch (BusinessException ex) {
             throw ex;
         } catch (Exception ex) {
             log.warn("Proxy config reload via {} failed: {}", url, 
ex.getMessage());
+            recordAudit(Operation.RELOAD_PROXY_CONFIG, ResourceType.PROXY, 
normalized, normalizedClusterId,
+                    Result.FAILED, "Config reload failed");
             throw new BusinessException(500, "Config reload failed: " + 
ex.getMessage());
         }
     }
@@ -324,4 +357,22 @@ public class ProxyAddressService {
         }
         return normalized;
     }
+
+    private void recordAudit(String operation, String resourceType, String 
resourceName, String clusterId) {
+        recordAudit(operation, resourceType, resourceName, clusterId, 
Result.SUCCESS, null);
+    }
+
+    private void recordAudit(String operation, String resourceType, String 
resourceName, String clusterId,
+                             String result, String errorMessage) {
+        if (operationAuditService == null) {
+            return;
+        }
+        try {
+            operationAuditService.record(operation, resourceType, 
resourceName, clusterId, null,
+                    result, errorMessage);
+        } catch (Exception auditFailure) {
+            log.warn("Failed to record audit operation={} resource={}: {}", 
operation, resourceName,
+                    auditFailure.getMessage());
+        }
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
index ded09bee3..4617fae3c 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
@@ -16,6 +16,10 @@
  */
 package org.apache.rocketmq.studio.instance.topic;
 
+import org.apache.rocketmq.studio.audit.OperationAuditConstants.Operation;
+import org.apache.rocketmq.studio.audit.OperationAuditConstants.ResourceType;
+import org.apache.rocketmq.studio.audit.OperationAuditConstants.Result;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.provider.apache.AdminClient;
 import org.apache.rocketmq.studio.provider.apache.MetadataProvider;
 import org.apache.rocketmq.studio.common.domain.PageResult;
@@ -34,6 +38,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
+import java.util.function.Supplier;
 
 @Slf4j
 @Service
@@ -46,6 +51,7 @@ public class MetadataService {
     private final AdminClient adminClient;
     private final InstanceProviderRegistry providerRegistry;
     private final org.apache.rocketmq.studio.instance.InstanceRepository 
instanceRepository;
+    private final OperationAuditService operationAuditService;
 
     /**
      * External callers address instances by their globally unique instance ID 
(name);
@@ -100,14 +106,18 @@ public class MetadataService {
     public TopicVO createTopic(TopicVO topic) {
         requireTopic(topic);
         String instanceId = topic.getInstanceId();
-        return resolve(instanceId).createTopic(instanceId, topic);
+        InstanceProvider provider = resolve(instanceId);
+        return executeWithAudit(provider, Operation.CREATE_TOPIC, 
ResourceType.TOPIC, topic.getName(),
+                instanceId, topicDetail(topic), () -> 
provider.createTopic(instanceId, topic));
     }
 
 
     public TopicVO updateTopic(TopicVO topic) {
         requireTopic(topic);
         String instanceId = topic.getInstanceId();
-        return resolve(instanceId).updateTopic(instanceId, topic);
+        InstanceProvider provider = resolve(instanceId);
+        return executeWithAudit(provider, Operation.UPDATE_TOPIC, 
ResourceType.TOPIC, topic.getName(),
+                instanceId, topicDetail(topic), () -> 
provider.updateTopic(instanceId, topic));
     }
 
 
@@ -117,7 +127,11 @@ public class MetadataService {
 
     public void deleteTopic(String instanceId, String name) {
         instanceId = normalizeInstanceId(instanceId);
-        resolve(instanceId).deleteTopic(instanceId, requireName(name, "topic 
name"));
+        String topicName = requireName(name, "topic name");
+        InstanceProvider provider = resolve(instanceId);
+        String normalizedInstanceId = instanceId;
+        executeWithAudit(provider, Operation.DELETE_TOPIC, ResourceType.TOPIC,
+                topicName, instanceId, null, () -> 
provider.deleteTopic(normalizedInstanceId, topicName));
     }
 
 
@@ -253,7 +267,10 @@ public class MetadataService {
 
     public ConsumerGroupVO createConsumerGroup(ConsumerGroupVO group) {
         String instanceId = group == null ? null : group.getInstanceId();
-        return resolve(instanceId).createConsumerGroup(instanceId, group);
+        InstanceProvider provider = resolve(instanceId);
+        return executeWithAudit(provider, Operation.CREATE_GROUP, 
ResourceType.GROUP,
+                group == null ? null : group.getName(), instanceId, 
consumerGroupDetail(group),
+                () -> provider.createConsumerGroup(instanceId, group));
     }
 
     public ConsumerGroupSettingsVO getConsumerGroupSettings(String instanceId, 
String name) {
@@ -266,8 +283,8 @@ public class MetadataService {
                                                                  int 
retryMaxTimes) {
         instanceId = normalizeInstanceId(instanceId);
         requireApacheInstance(instanceId);
-        return adminClient.updateConsumerGroupSettings(instanceId, 
requireName(name, "consumer group name"),
-                retryQueueNums, retryMaxTimes);
+        String groupName = requireName(name, "consumer group name");
+        return adminClient.updateConsumerGroupSettings(instanceId, groupName, 
retryQueueNums, retryMaxTimes);
     }
 
 
@@ -277,7 +294,11 @@ public class MetadataService {
 
     public void deleteConsumerGroup(String instanceId, String name) {
         instanceId = normalizeInstanceId(instanceId);
-        resolve(instanceId).deleteConsumerGroup(instanceId, name);
+        String groupName = requireName(name, "consumer group name");
+        InstanceProvider provider = resolve(instanceId);
+        String normalizedInstanceId = instanceId;
+        executeWithAudit(provider, Operation.DELETE_GROUP, ResourceType.GROUP,
+                groupName, instanceId, null, () -> 
provider.deleteConsumerGroup(normalizedInstanceId, groupName));
     }
 
 
@@ -287,7 +308,12 @@ public class MetadataService {
 
     public void resetOffset(String instanceId, String name, long timestamp, 
String topic) {
         instanceId = normalizeInstanceId(instanceId);
-        resolve(instanceId).resetOffset(instanceId, name, timestamp, topic);
+        String groupName = requireName(name, "consumer group name");
+        InstanceProvider provider = resolve(instanceId);
+        String normalizedInstanceId = instanceId;
+        executeWithAudit(provider, Operation.RESET_OFFSET, ResourceType.GROUP, 
groupName, instanceId,
+                "topic=" + optionalDetail(topic) + ", timestamp=" + timestamp,
+                () -> provider.resetOffset(normalizedInstanceId, groupName, 
timestamp, topic));
     }
 
 
@@ -337,4 +363,71 @@ public class MetadataService {
         }
         return value.trim();
     }
+
+    private String topicDetail(TopicVO topic) {
+        return "type=" + optionalDetail(topic.getType())
+                + ", writeQueues=" + topic.getWriteQueues()
+                + ", readQueues=" + topic.getReadQueues()
+                + ", perm=" + optionalDetail(topic.getPerm());
+    }
+
+    private String consumerGroupDetail(ConsumerGroupVO group) {
+        if (group == null) {
+            return null;
+        }
+        return "consumeType=" + optionalDetail(group.getConsumeType())
+                + ", subscriptionMode=" + 
optionalDetail(group.getSubscriptionMode())
+                + ", retryMaxTimes=" + group.getRetryMaxTimes();
+    }
+
+    private String optionalDetail(Object value) {
+        if (value == null) {
+            return "-";
+        }
+        String text = value.toString();
+        return StringUtils.hasText(text) ? text.trim() : "-";
+    }
+
+    private <T> T executeWithAudit(InstanceProvider provider, String 
operation, String resourceType,
+                                   String resourceName, String instanceId, 
String detail, Supplier<T> action) {
+        if (provider.vendor() == InstanceVendor.APACHE) {
+            return action.get();
+        }
+        try {
+            T result = action.get();
+            recordAudit(operation, resourceType, resourceName, instanceId, 
detail, Result.SUCCESS, null);
+            return result;
+        } catch (RuntimeException failure) {
+            recordAudit(operation, resourceType, resourceName, instanceId, 
detail, Result.FAILED,
+                    failure.getMessage());
+            throw failure;
+        }
+    }
+
+    private void executeWithAudit(InstanceProvider provider, String operation, 
String resourceType,
+                                  String resourceName, String instanceId, 
String detail, Runnable action) {
+        if (provider.vendor() == InstanceVendor.APACHE) {
+            action.run();
+            return;
+        }
+        try {
+            action.run();
+            recordAudit(operation, resourceType, resourceName, instanceId, 
detail, Result.SUCCESS, null);
+        } catch (RuntimeException failure) {
+            recordAudit(operation, resourceType, resourceName, instanceId, 
detail, Result.FAILED,
+                    failure.getMessage());
+            throw failure;
+        }
+    }
+
+    private void recordAudit(String operation, String resourceType, String 
resourceName, String instanceId,
+                             String detail, String result, String 
errorMessage) {
+        try {
+            operationAuditService.record(operation, resourceType, 
resourceName, instanceId,
+                    detail, result, errorMessage);
+        } catch (Exception auditFailure) {
+            log.warn("Failed to record audit operation={} resource={}: {}", 
operation, resourceName,
+                    auditFailure.getMessage());
+        }
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
index cada952fd..13e0a478e 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/proxy/ProxyAddressServiceTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.rocketmq.studio.cluster.proxy;
 
+import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.cluster.broker.ClusterService;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.junit.jupiter.api.BeforeEach;
@@ -38,6 +39,7 @@ import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
@@ -51,12 +53,16 @@ class ProxyAddressServiceTest {
     @Mock
     private RestTemplate restTemplate;
 
+    @Mock
+    private OperationAuditService operationAuditService;
+
     private final ProxyHealthProbe healthProbe = mock(ProxyHealthProbe.class);
     private ProxyAddressService proxyAddressService;
 
     @BeforeEach
     void setUp() {
-        proxyAddressService = new ProxyAddressService(clusterService, 
healthProbe, restTemplate);
+        proxyAddressService = new ProxyAddressService(clusterService, 
healthProbe, restTemplate,
+                operationAuditService);
         // Default probe outcome: everything reachable with 1 ms latency.
         when(healthProbe.probe(anyString(), anyInt(), anyInt()))
                 .thenReturn(ProxyHealthProbe.ProbeResult.reachable(1L));
@@ -78,6 +84,8 @@ class ProxyAddressServiceTest {
         ProxyHomeVO home = proxyAddressService.getHomePage();
         assertThat(home.getProxyAddrList()).containsExactly("127.0.0.1:8081", 
"10.0.0.1:8081");
         assertThat(home.getCurrentProxyAddr()).isEqualTo("127.0.0.1:8081");
+        verify(operationAuditService, times(1)).record("ADD_PROXY_ADDRESS", 
"PROXY", "10.0.0.1:8081",
+                null, null, "SUCCESS", null);
     }
 
     @Test
@@ -127,6 +135,19 @@ class ProxyAddressServiceTest {
         ProxyHomeVO home = proxyAddressService.getHomePage();
         assertThat(home.getProxyAddrList()).containsExactly("127.0.0.1:8081");
         assertThat(home.getCurrentProxyAddr()).isEqualTo("127.0.0.1:8081");
+        verify(operationAuditService).record("REMOVE_PROXY_ADDRESS", "PROXY", 
"10.0.0.1:8081",
+                null, null, "SUCCESS", null);
+    }
+
+    @Test
+    void auditFailureShouldNotAbortProxyAddressMutation() {
+        doThrow(new RuntimeException("audit 
unavailable")).when(operationAuditService)
+                .record("ADD_PROXY_ADDRESS", "PROXY", "10.0.0.1:8081", null, 
null, "SUCCESS", null);
+
+        proxyAddressService.addProxyAddr("10.0.0.1:8081");
+
+        assertThat(proxyAddressService.getHomePage().getProxyAddrList())
+                .containsExactly("127.0.0.1:8081", "10.0.0.1:8081");
     }
 
     @Test
@@ -198,6 +219,8 @@ class ProxyAddressServiceTest {
 
         verify(clusterService).requireProxy("cluster-1", "10.0.0.10:8081");
         
verify(restTemplate).postForEntity(eq("http://10.0.0.10:8081/admin/reloadConfig";),
 isNull(), eq(String.class));
+        verify(operationAuditService).record("RELOAD_PROXY_CONFIG", "PROXY", 
"10.0.0.10:8081",
+                "cluster-1", null, "SUCCESS", null);
     }
 
     @Test
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
index 595be3523..82612234e 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.rocketmq.studio.instance.topic;
 
+import org.apache.rocketmq.studio.audit.OperationAuditService;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -37,6 +38,7 @@ import java.util.List;
 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.Mockito.doThrow;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -57,9 +59,15 @@ class MetadataServiceTest {
     @Mock
     private InstanceProvider apacheProvider;
 
+    @Mock
+    private InstanceProvider cloudProvider;
+
     @Mock
     private org.apache.rocketmq.studio.instance.InstanceRepository 
instanceRepository;
 
+    @Mock
+    private OperationAuditService operationAuditService;
+
     @InjectMocks
     private MetadataService metadataService;
 
@@ -67,7 +75,9 @@ class MetadataServiceTest {
     void routeBlankInstanceIdsToApacheProvider() {
         
lenient().when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(apacheProvider);
         
lenient().when(apacheProvider.vendor()).thenReturn(InstanceVendor.APACHE);
+        
lenient().when(cloudProvider.vendor()).thenReturn(InstanceVendor.TENCENT);
         
lenient().when(providerRegistry.byInstanceId("instance-a")).thenReturn(java.util.Optional.of(apacheProvider));
+        
lenient().when(providerRegistry.byInstanceId("cloud-instance")).thenReturn(java.util.Optional.of(cloudProvider));
         
lenient().when(instanceRepository.findByIdentifier(org.mockito.ArgumentMatchers.anyString()))
                 .thenReturn(java.util.Optional.empty());
     }
@@ -155,6 +165,80 @@ class MetadataServiceTest {
 
         assertThat(result.getName()).isEqualTo("new-topic");
         verify(apacheProvider).createTopic(null, input);
+        verifyNoInteractions(operationAuditService);
+    }
+
+    @Test
+    void apacheTopicWriteOperationsShouldNotDuplicateProviderAudit() {
+        TopicVO topic = new TopicVO();
+        topic.setName("orders");
+        topic.setInstanceId("instance-a");
+        topic.setWriteQueues(4);
+        topic.setReadQueues(4);
+        when(apacheProvider.updateTopic("instance-a", 
topic)).thenReturn(topic);
+        SendMessageDTO message = SendMessageDTO.builder()
+                .instanceId("instance-a")
+                .topic("orders")
+                .tag("TagA")
+                .key("order-1")
+                .body("hello")
+                .build();
+        
when(adminClient.sendMessage(message)).thenReturn(SendMessageVO.builder().msgId("msg-1").build());
+
+        metadataService.updateTopic(topic);
+        metadataService.deleteTopic("instance-a", " orders ");
+        metadataService.sendMessage(message);
+
+        verifyNoInteractions(operationAuditService);
+    }
+
+    @Test
+    void cloudTopicWriteOperationsShouldRecordServiceBoundaryAudit() {
+        TopicVO topic = new TopicVO();
+        topic.setName("orders");
+        topic.setInstanceId("cloud-instance");
+        topic.setWriteQueues(4);
+        topic.setReadQueues(4);
+        when(cloudProvider.createTopic("cloud-instance", 
topic)).thenReturn(topic);
+        when(cloudProvider.updateTopic("cloud-instance", 
topic)).thenReturn(topic);
+
+        metadataService.createTopic(topic);
+        metadataService.updateTopic(topic);
+        metadataService.deleteTopic("cloud-instance", " orders ");
+
+        verify(operationAuditService).record("CREATE_TOPIC", "TOPIC", 
"orders", "cloud-instance",
+                "type=-, writeQueues=4, readQueues=4, perm=-", "SUCCESS", 
null);
+        verify(operationAuditService).record("UPDATE_TOPIC", "TOPIC", 
"orders", "cloud-instance",
+                "type=-, writeQueues=4, readQueues=4, perm=-", "SUCCESS", 
null);
+        verify(operationAuditService).record("DELETE_TOPIC", "TOPIC", 
"orders", "cloud-instance",
+                null, "SUCCESS", null);
+    }
+
+    @Test
+    void auditFailureShouldNotAbortCloudMetadataOperation() {
+        doThrow(new RuntimeException("audit 
unavailable")).when(operationAuditService)
+                .record("DELETE_TOPIC", "TOPIC", "orders", "cloud-instance", 
null, "SUCCESS", null);
+
+        metadataService.deleteTopic("cloud-instance", "orders");
+
+        verify(cloudProvider).deleteTopic("cloud-instance", "orders");
+    }
+
+    @Test
+    void failedCloudMetadataOperationShouldRecordFailedAuditTest() {
+        TopicVO topic = new TopicVO();
+        topic.setName("orders");
+        topic.setInstanceId("cloud-instance");
+        topic.setWriteQueues(4);
+        topic.setReadQueues(4);
+        when(cloudProvider.createTopic("cloud-instance", topic))
+                .thenThrow(new BusinessException(502, "open api unavailable"));
+
+        assertThatThrownBy(() -> metadataService.createTopic(topic))
+                .isInstanceOf(BusinessException.class);
+
+        verify(operationAuditService).record("CREATE_TOPIC", "TOPIC", 
"orders", "cloud-instance",
+                "type=-, writeQueues=4, readQueues=4, perm=-", "FAILED", "open 
api unavailable");
     }
 
     @Test
@@ -247,6 +331,7 @@ class MetadataServiceTest {
         assertThat(result.getMsgId()).isEqualTo("msg-001");
         assertThat(result.getOffsetMsgId()).isEqualTo("offset-001");
         verify(adminClient).sendMessage(request);
+        verifyNoInteractions(operationAuditService);
     }
 
     @Test
@@ -353,4 +438,24 @@ class MetadataServiceTest {
         assertThat(metadataService.refreshConsumerGroup("instance-a", 
"cg-gone")).isNull();
     }
 
+    @Test
+    void cloudConsumerGroupWriteOperationsShouldRecordServiceBoundaryAudit() {
+        ConsumerGroupVO group = new ConsumerGroupVO();
+        group.setName("cg-orders");
+        group.setInstanceId("cloud-instance");
+        group.setRetryMaxTimes(16);
+        when(cloudProvider.createConsumerGroup("cloud-instance", 
group)).thenReturn(group);
+
+        metadataService.createConsumerGroup(group);
+        metadataService.deleteConsumerGroup("cloud-instance", " cg-orders ");
+        metadataService.resetOffset("cloud-instance", " cg-orders ", 
1784246400000L, "orders");
+
+        verify(operationAuditService).record("CREATE_GROUP", "GROUP", 
"cg-orders",
+                "cloud-instance", "consumeType=-, subscriptionMode=-, 
retryMaxTimes=16", "SUCCESS", null);
+        verify(operationAuditService).record("DELETE_GROUP", "GROUP", 
"cg-orders",
+                "cloud-instance", null, "SUCCESS", null);
+        verify(operationAuditService).record("RESET_OFFSET", "GROUP", 
"cg-orders",
+                "cloud-instance", "topic=orders, timestamp=1784246400000", 
"SUCCESS", null);
+    }
+
 }
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 4be81ab23..a8873d13f 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -746,6 +746,75 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'audit.resourceType': { zh: '资源类型', en: 'Resource Type' },
   'audit.target': { zh: '操作对象', en: 'Target' },
   'audit.detail': { zh: '操作详情', en: 'Detail' },
+  'audit.op.CREATE_TOPIC': { zh: '创建 Topic', en: 'Create Topic' },
+  'audit.op.UPDATE_TOPIC': { zh: '更新 Topic', en: 'Update Topic' },
+  'audit.op.DELETE_TOPIC': { zh: '删除 Topic', en: 'Delete Topic' },
+  'audit.op.CREATE_GROUP': { zh: '创建消费组', en: 'Create Group' },
+  'audit.op.UPDATE_GROUP': { zh: '更新消费组', en: 'Update Group' },
+  'audit.op.DELETE_GROUP': { zh: '删除消费组', en: 'Delete Group' },
+  'audit.op.RESET_OFFSET': { zh: '重置消费位点', en: 'Reset Offset' },
+  'audit.op.ADD_PROXY_ADDRESS': { zh: '新增 Proxy 地址', en: 'Add Proxy Address' },
+  'audit.op.REMOVE_PROXY_ADDRESS': { zh: '删除 Proxy 地址', en: 'Remove Proxy 
Address' },
+  'audit.op.RELOAD_PROXY_CONFIG': { zh: '重载 Proxy 配置', en: 'Reload Proxy 
Config' },
+  'audit.op.SEND_MESSAGE': { zh: '发送消息', en: 'Send Message' },
+  'audit.op.RESEND_DLQ': { zh: '重发死信消息', en: 'Resend DLQ' },
+  'audit.op.DIRECT_CONSUME_MESSAGE': { zh: '直接消费消息', en: 'Direct Consume 
Message' },
+  'audit.op.UPDATE_BROKER_CONFIG': { zh: '更新 Broker 配置', en: 'Update Broker 
Config' },
+  'audit.op.UPDATE_CLUSTER_CONFIG': { zh: '更新集群配置', en: 'Update Cluster 
Config' },
+  'audit.op.RESTART_BROKER': { zh: '重启 Broker', en: 'Restart Broker' },
+  'audit.op.CREATE_ACL_RULE': { zh: '创建 ACL 规则', en: 'Create ACL Rule' },
+  'audit.op.UPDATE_ACL_RULE': { zh: '更新 ACL 规则', en: 'Update ACL Rule' },
+  'audit.op.DELETE_ACL_RULE': { zh: '删除 ACL 规则', en: 'Delete ACL Rule' },
+  'audit.op.CREATE_ACL_USER': { zh: '创建 ACL 用户', en: 'Create ACL User' },
+  'audit.op.UPDATE_ACL_USER': { zh: '更新 ACL 用户', en: 'Update ACL User' },
+  'audit.op.DELETE_ACL_USER': { zh: '删除 ACL 用户', en: 'Delete ACL User' },
+  'audit.op.UPSERT_PLAIN_ACCESS_CONFIG': {
+    zh: '更新 Plain Access 配置',
+    en: 'Upsert Plain Access Config',
+  },
+  'audit.op.UPDATE_SETTINGS': { zh: '更新设置', en: 'Update Settings' },
+  'audit.op.CREATE_DATA_SOURCE': { zh: '创建数据源', en: 'Create Data Source' },
+  'audit.op.UPDATE_DATA_SOURCE': { zh: '更新数据源', en: 'Update Data Source' },
+  'audit.op.DELETE_DATA_SOURCE': { zh: '删除数据源', en: 'Delete Data Source' },
+  'audit.op.CREATE_CLOUD_CREDENTIAL': { zh: '创建云凭据', en: 'Create Cloud 
Credential' },
+  'audit.op.UPDATE_CLOUD_CREDENTIAL': { zh: '更新云凭据', en: 'Update Cloud 
Credential' },
+  'audit.op.DELETE_CLOUD_CREDENTIAL': { zh: '删除云凭据', en: 'Delete Cloud 
Credential' },
+  'audit.op.CREATE_ALERT_RULE': { zh: '创建告警规则', en: 'Create Alert Rule' },
+  'audit.op.UPDATE_ALERT_RULE': { zh: '更新告警规则', en: 'Update Alert Rule' },
+  'audit.op.TOGGLE_ALERT_RULE': { zh: '启停告警规则', en: 'Toggle Alert Rule' },
+  'audit.op.DELETE_ALERT_RULE': { zh: '删除告警规则', en: 'Delete Alert Rule' },
+  'audit.op.ACKNOWLEDGE_SYSTEM_ALERT': { zh: '确认系统告警', en: 'Acknowledge System 
Alert' },
+  'audit.op.CLEAR_ACKNOWLEDGED_SYSTEM_ALERTS': {
+    zh: '清除已确认告警',
+    en: 'Clear Acknowledged Alerts',
+  },
+  'audit.op.CREATE_INSTANCE': { zh: '创建实例', en: 'Create Instance' },
+  'audit.op.UPDATE_INSTANCE': { zh: '更新实例', en: 'Update Instance' },
+  'audit.op.DELETE_INSTANCE': { zh: '删除实例', en: 'Delete Instance' },
+  'audit.op.CREATE_K8S_CERTIFICATE': { zh: '创建 K8s 证书', en: 'Create K8s 
Certificate' },
+  'audit.op.UPDATE_K8S_CERTIFICATE': { zh: '更新 K8s 证书', en: 'Update K8s 
Certificate' },
+  'audit.op.RENEW_K8S_CERTIFICATE': { zh: '续期 K8s 证书', en: 'Renew K8s 
Certificate' },
+  'audit.op.DELETE_K8S_CERTIFICATE': { zh: '删除 K8s 证书', en: 'Delete K8s 
Certificate' },
+  'audit.res.TOPIC': { zh: 'Topic', en: 'Topic' },
+  'audit.res.GROUP': { zh: '消费组', en: 'Consumer Group' },
+  'audit.res.CONSUMER_GROUP': { zh: '消费组', en: 'Consumer Group' },
+  'audit.res.MESSAGE': { zh: '消息', en: 'Message' },
+  'audit.res.DLQ': { zh: '死信队列', en: 'DLQ' },
+  'audit.res.PROXY': { zh: 'Proxy', en: 'Proxy' },
+  'audit.res.BROKER': { zh: 'Broker', en: 'Broker' },
+  'audit.res.CLUSTER': { zh: '集群', en: 'Cluster' },
+  'audit.res.INSTANCE': { zh: '实例', en: 'Instance' },
+  'audit.res.ACL_RULE': { zh: 'ACL 规则', en: 'ACL Rule' },
+  'audit.res.ACL_USER': { zh: 'ACL 用户', en: 'ACL User' },
+  'audit.res.SETTINGS': { zh: '设置', en: 'Settings' },
+  'audit.res.METRICS_DATA_SOURCE': { zh: '指标数据源', en: 'Metrics Data Source' },
+  'audit.res.CLOUD_CREDENTIAL': { zh: '云凭据', en: 'Cloud Credential' },
+  'audit.res.ALERT_RULE': { zh: '告警规则', en: 'Alert Rule' },
+  'audit.res.SYSTEM_ALERT': { zh: '系统告警', en: 'System Alert' },
+  'audit.res.K8S_CERTIFICATE': { zh: 'K8s 证书', en: 'K8s Certificate' },
+  'audit.result.SUCCESS': { zh: '成功', en: 'Success' },
+  'audit.result.PARTIAL': { zh: '部分成功', en: 'Partial' },
+  'audit.result.FAILED': { zh: '失败', en: 'Failed' },
   'audit.cluster': { zh: '集群', en: 'Cluster' },
   'audit.result': { zh: '结果', en: 'Result' },
   'audit.error': { zh: '失败原因', en: 'Error' },
diff --git a/web/src/mock/audit.ts b/web/src/mock/audit.ts
index 4ef4e9b4e..eb1ec1561 100644
--- a/web/src/mock/audit.ts
+++ b/web/src/mock/audit.ts
@@ -235,6 +235,90 @@ export const mockAuditRecords = [
     ipAddress: '10.0.1.3',
     result: 'SUCCESS' as const,
   },
+  {
+    id: 19,
+    timestamp: '2026-07-03 10:18:31',
+    operator: 'ops-chen',
+    operationType: 'ADD_PROXY_ADDRESS',
+    resourceType: 'PROXY',
+    clusterId: '',
+    target: '10.0.30.11:8081',
+    detail: 'Added Proxy address 10.0.30.11:8081',
+    ipAddress: '10.0.18.21',
+    result: 'SUCCESS' as const,
+  },
+  {
+    id: 20,
+    timestamp: '2026-07-03 10:16:09',
+    operator: 'ops-chen',
+    operationType: 'RELOAD_PROXY_CONFIG',
+    resourceType: 'PROXY',
+    clusterId: 'prod-cn',
+    target: '10.0.30.10:8081',
+    detail: 'Reloaded Proxy runtime config from Studio',
+    ipAddress: '10.0.18.21',
+    result: 'SUCCESS' as const,
+  },
+  {
+    id: 21,
+    timestamp: '2026-07-03 10:02:47',
+    operator: 'ops-li',
+    operationType: 'REMOVE_PROXY_ADDRESS',
+    resourceType: 'PROXY',
+    clusterId: '',
+    target: '10.0.30.9:8081',
+    detail: 'Removed stale Proxy address 10.0.30.9:8081',
+    ipAddress: '10.0.8.101',
+    result: 'SUCCESS' as const,
+  },
+  {
+    id: 22,
+    timestamp: '2026-07-03 09:58:22',
+    operator: 'cloud-admin',
+    operationType: 'CREATE_TOPIC',
+    resourceType: 'TOPIC',
+    clusterId: 'tencent-prod',
+    target: 'cloud-trade-events',
+    detail: 'type=NORMAL, writeQueues=8, readQueues=8, perm=6',
+    ipAddress: '10.0.22.11',
+    result: 'SUCCESS' as const,
+  },
+  {
+    id: 23,
+    timestamp: '2026-07-03 09:54:03',
+    operator: 'cloud-admin',
+    operationType: 'UPDATE_TOPIC',
+    resourceType: 'TOPIC',
+    clusterId: 'aliyun-prod',
+    target: 'cloud-payment-events',
+    detail: 'type=NORMAL, writeQueues=16, readQueues=16, perm=6',
+    ipAddress: '10.0.22.11',
+    result: 'SUCCESS' as const,
+  },
+  {
+    id: 24,
+    timestamp: '2026-07-03 09:41:37',
+    operator: 'cloud-admin',
+    operationType: 'CREATE_GROUP',
+    resourceType: 'GROUP',
+    clusterId: 'tencent-prod',
+    target: 'cloud-trade-consumer',
+    detail: 'consumeType=PUSH, subscriptionMode=CLUSTERING, retryMaxTimes=16',
+    ipAddress: '10.0.22.11',
+    result: 'SUCCESS' as const,
+  },
+  {
+    id: 25,
+    timestamp: '2026-07-03 09:37:55',
+    operator: 'cloud-admin',
+    operationType: 'RESET_OFFSET',
+    resourceType: 'GROUP',
+    clusterId: 'aliyun-prod',
+    target: 'cloud-payment-consumer',
+    detail: 'topic=cloud-payment-events, timestamp=1784246400000',
+    ipAddress: '10.0.22.11',
+    result: 'SUCCESS' as const,
+  },
 ] as const;
 
 export type AuditRecord = (typeof mockAuditRecords)[number];
diff --git a/web/src/pages/ops/__tests__/AuditPage.test.tsx 
b/web/src/pages/ops/__tests__/AuditPage.test.tsx
index 65489e0a7..e50a44dd0 100644
--- a/web/src/pages/ops/__tests__/AuditPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AuditPage.test.tsx
@@ -69,8 +69,8 @@ describe('Audit page', () => {
 
   beforeEach(() => {
     vi.mocked(opsService.getAuditFilterOptions).mockResolvedValue({
-      operationTypes: ['CREATE_TOPIC', 'RESET_OFFSET'],
-      resourceTypes: ['CONSUMER_GROUP', 'TOPIC'],
+      operationTypes: ['ADD_PROXY_ADDRESS', 'CREATE_TOPIC', 'RESET_OFFSET'],
+      resourceTypes: ['CONSUMER_GROUP', 'PROXY', 'TOPIC'],
       clusterIds: ['prod-cn', 'prod-sh'],
       results: ['FAILED', 'PARTIAL', 'SUCCESS'],
     });
@@ -145,6 +145,36 @@ describe('Audit page', () => {
     expect(revokeObjectURL).toHaveBeenCalledWith('blob:audit');
   });
 
+  it('renders control-plane audit labels and parsed detail values', async () 
=> {
+    vi.mocked(opsService.listAuditRecords).mockResolvedValueOnce({
+      items: [
+        {
+          id: 2,
+          timestamp: '2026-08-01 11:00:00',
+          operator: 'ops-chen',
+          operationType: 'RELOAD_PROXY_CONFIG',
+          resourceType: 'PROXY',
+          target: '10.0.30.10:8081',
+          clusterId: 'prod-cn',
+          detail: 'topic=orders, timestamp=1784246400000',
+          result: 'SUCCESS',
+          errorMessage: '',
+        },
+      ],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+
+    renderWithProviders(<AuditPage />);
+
+    expect(await screen.findByText('重载 Proxy 配置')).toBeInTheDocument();
+    expect(screen.getByText('Proxy')).toBeInTheDocument();
+    expect(screen.getByText('成功')).toBeInTheDocument();
+    expect(screen.getByText('topic: orders')).toBeInTheDocument();
+    expect(screen.getByText('timestamp: 1784246400000')).toBeInTheDocument();
+  });
+
   it('loads persisted filter values and forwards their original codes', async 
() => {
     const user = userEvent.setup();
     renderWithProviders(<AuditPage />);
@@ -152,12 +182,12 @@ describe('Audit page', () => {
     expect(await screen.findByText('topic-a')).toBeInTheDocument();
     await user.click(screen.getByRole('combobox', { name: '操作类型' }));
     await user.click(
-      await screen.findByText('CREATE TOPIC', { selector: 
'.ant-select-item-option-content' }),
+      await screen.findByText('创建 Topic', { selector: 
'.ant-select-item-option-content' }),
     );
-    expect(screen.getByText('SUCCESS')).toBeInTheDocument();
+    expect(screen.getByText('成功')).toBeInTheDocument();
     await user.click(screen.getByRole('combobox', { name: '资源类型' }));
     await user.click(
-      await screen.findByText('CONSUMER GROUP', {
+      await screen.findByText('消费组', {
         selector: '.ant-select-item-option-content',
       }),
     );
@@ -209,7 +239,7 @@ describe('Audit page', () => {
 
     await user.click(screen.getByRole('combobox', { name: '操作类型' }));
     await user.click(
-      await screen.findByText('CREATE TOPIC', { selector: 
'.ant-select-item-option-content' }),
+      await screen.findByText('创建 Topic', { selector: 
'.ant-select-item-option-content' }),
     );
 
     await waitFor(() => 
expect(opsService.listAuditRecords).toHaveBeenCalledTimes(2));
@@ -218,8 +248,7 @@ describe('Audit page', () => {
 
   it('ignores stale filter-option responses after cleanup refreshes', async () 
=> {
     const user = userEvent.setup();
-    const staleOptions =
-      deferred<Awaited<ReturnType<typeof opsService.getAuditFilterOptions>>>();
+    const staleOptions = deferred<Awaited<ReturnType<typeof 
opsService.getAuditFilterOptions>>>();
     vi.mocked(opsService.getAuditFilterOptions)
       .mockImplementationOnce(() => new Promise(() => {}))
       .mockImplementationOnce(() => staleOptions.promise);
@@ -231,9 +260,7 @@ describe('Audit page', () => {
     await user.click(screen.getByRole('button', { name: /清理日志/ }));
     await user.click(await screen.findByRole('button', { name: /确认清理/ }));
 
-    await waitFor(() =>
-      expect(opsService.getAuditFilterOptions).toHaveBeenNthCalledWith(2),
-    );
+    await waitFor(() => 
expect(opsService.getAuditFilterOptions).toHaveBeenNthCalledWith(2));
 
     await act(async () => {
       staleOptions.resolve({
@@ -245,7 +272,7 @@ describe('Audit page', () => {
     });
 
     await user.click(screen.getByRole('combobox', { name: '操作类型' }));
-    expect(await screen.findByText('STALE OPERATION')).toBeInTheDocument();
+    expect(await screen.findByText('Stale Operation')).toBeInTheDocument();
   });
 
   it('still loads audit records when filter options cannot be loaded', async 
() => {
diff --git a/web/src/pages/ops/__tests__/auditPresentation.test.ts 
b/web/src/pages/ops/__tests__/auditPresentation.test.ts
new file mode 100644
index 000000000..b030e9a3d
--- /dev/null
+++ b/web/src/pages/ops/__tests__/auditPresentation.test.ts
@@ -0,0 +1,148 @@
+/*
+ * 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, expect, it } from 'vitest';
+import type { AuditRecord } from '../../../api/ops';
+import {
+  describeAuditRecord,
+  formatAuditCode,
+  getAuditOperationPresentation,
+  getAuditResourcePresentation,
+  getAuditResultPresentation,
+  isControlPlaneAuditRecord,
+  normalizeAuditCode,
+  parseAuditDetail,
+} from '../auditPresentation';
+
+describe('audit presentation helpers', () => {
+  it('normalizes audit codes before presentation lookup', () => {
+    expect(normalizeAuditCode(' add_proxy_address 
')).toBe('ADD_PROXY_ADDRESS');
+    expect(formatAuditCode(' reload_proxy_config ')).toBe('Reload Proxy 
Config');
+    expect(formatAuditCode('')).toBe('-');
+  });
+
+  it('presents proxy control-plane operations with stable labels and colors', 
() => {
+    expect(getAuditOperationPresentation('ADD_PROXY_ADDRESS')).toEqual({
+      label: 'Add Proxy Address',
+      color: 'geekblue',
+      category: 'proxy',
+      labelKey: 'audit.op.ADD_PROXY_ADDRESS',
+    });
+    expect(getAuditOperationPresentation('REMOVE_PROXY_ADDRESS')).toEqual({
+      labelKey: 'audit.op.REMOVE_PROXY_ADDRESS',
+      label: 'Remove Proxy Address',
+      color: 'volcano',
+      category: 'proxy',
+    });
+    expect(getAuditOperationPresentation('RELOAD_PROXY_CONFIG')).toEqual({
+      labelKey: 'audit.op.RELOAD_PROXY_CONFIG',
+      label: 'Reload Proxy Config',
+      color: 'purple',
+      category: 'proxy',
+    });
+  });
+
+  it('presents metadata operations without losing the original audit code', () 
=> {
+    expect(getAuditOperationPresentation('CREATE_TOPIC')).toMatchObject({
+      label: 'Create Topic',
+      category: 'metadata',
+    });
+    expect(getAuditOperationPresentation('RESET_OFFSET')).toMatchObject({
+      label: 'Reset Offset',
+      category: 'metadata',
+    });
+  });
+
+  it('falls back to title-cased labels for new operation codes', () => {
+    expect(getAuditOperationPresentation('UPSERT_NEW_RESOURCE')).toEqual({
+      label: 'Upsert New Resource',
+      color: 'default',
+      category: 'other',
+    });
+  });
+
+  it('maps resource and result codes to readable table labels', () => {
+    expect(getAuditResourcePresentation('PROXY')).toEqual({
+      label: 'Proxy',
+      color: 'purple',
+      labelKey: 'audit.res.PROXY',
+    });
+    expect(getAuditResourcePresentation('GROUP')).toEqual({
+      label: 'Consumer Group',
+      color: 'geekblue',
+      labelKey: 'audit.res.GROUP',
+    });
+    expect(getAuditResourcePresentation('CONSUMER_GROUP')).toEqual({
+      label: 'Consumer Group',
+      color: 'geekblue',
+      labelKey: 'audit.res.CONSUMER_GROUP',
+    });
+    expect(getAuditResultPresentation('SUCCESS')).toEqual({
+      label: 'Success',
+      color: 'green',
+      labelKey: 'audit.result.SUCCESS',
+    });
+    expect(getAuditResultPresentation('FAILED')).toEqual({
+      label: 'Failed',
+      color: 'red',
+      labelKey: 'audit.result.FAILED',
+    });
+  });
+
+  it('parses key-value audit details from service-boundary metadata records', 
() => {
+    expect(parseAuditDetail('type=NORMAL, writeQueues=16, readQueues=16, 
perm=6')).toEqual([
+      { label: 'type', value: 'NORMAL' },
+      { label: 'writeQueues', value: '16' },
+      { label: 'readQueues', value: '16' },
+      { label: 'perm', value: '6' },
+    ]);
+    expect(parseAuditDetail('topic=orders, timestamp=1784246400000')).toEqual([
+      { label: 'topic', value: 'orders' },
+      { label: 'timestamp', value: '1784246400000' },
+    ]);
+  });
+
+  it('keeps free-form details intact when they are not key-value lists', () => 
{
+    expect(parseAuditDetail('Removed stale Proxy address 
10.0.30.9:8081')).toEqual([
+      { label: '', value: 'Removed stale Proxy address 10.0.30.9:8081' },
+    ]);
+    expect(parseAuditDetail('created topic, owner missing')).toEqual([
+      { label: '', value: 'created topic, owner missing' },
+    ]);
+    expect(parseAuditDetail(null)).toEqual([]);
+  });
+
+  it('describes records with operation, resource, target, and cluster 
context', () => {
+    const record = {
+      operationType: 'RELOAD_PROXY_CONFIG',
+      resourceType: 'PROXY',
+      target: '10.0.30.10:8081',
+      clusterId: 'prod-cn',
+    } as AuditRecord;
+
+    expect(describeAuditRecord(record)).toBe(
+      'Reload Proxy Config Proxy 10.0.30.10:8081 in prod-cn',
+    );
+  });
+
+  it('identifies metadata, proxy, and cluster actions as control-plane 
records', () => {
+    expect(isControlPlaneAuditRecord({ operationType: 'CREATE_TOPIC' 
})).toBe(true);
+    expect(isControlPlaneAuditRecord({ operationType: 'RELOAD_PROXY_CONFIG' 
})).toBe(true);
+    expect(isControlPlaneAuditRecord({ operationType: 'UPDATE_BROKER_CONFIG' 
})).toBe(true);
+    expect(isControlPlaneAuditRecord({ operationType: 'SEND_MESSAGE' 
})).toBe(false);
+  });
+});
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index b7fcbcef4..c0d6035fb 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -29,6 +29,8 @@ import {
   InputNumber,
   Typography,
   message,
+  Space,
+  Tooltip,
 } from 'antd';
 import { Trash } from '@phosphor-icons/react';
 import { DownloadOutlined } from '@ant-design/icons';
@@ -48,6 +50,13 @@ import {
 import { downloadBlob } from '../../utils/download';
 import { formatDateTime } from '../../utils/format';
 import { tableScrollX } from '../../utils/table';
+import {
+  describeAuditRecord,
+  getAuditOperationPresentation,
+  getAuditResourcePresentation,
+  getAuditResultPresentation,
+  parseAuditDetail,
+} from './auditPresentation';
 
 const emptyFilterOptions: AuditFilterOptions = {
   operationTypes: [],
@@ -56,15 +65,6 @@ const emptyFilterOptions: AuditFilterOptions = {
   results: [],
 };
 
-const formatFilterLabel = (value: string) => value.trim().replace(/_/g, ' ');
-
-const resultColor = (result: string) => {
-  const normalized = result.toUpperCase();
-  if (normalized === 'SUCCESS') return 'green';
-  if (normalized === 'PARTIAL') return 'orange';
-  return 'red';
-};
-
 const buildAuditFilter = (
   searchText: string,
   selectedType: string | undefined,
@@ -202,6 +202,60 @@ const AuditPage: React.FC = () => {
 
   const { Text } = Typography;
 
+  const renderOperationType = (type: string) => {
+    const presentation = getAuditOperationPresentation(type);
+    return (
+      <Tooltip title={type}>
+        <Tag color={presentation.color}>
+          {presentation.labelKey ? t(presentation.labelKey) : 
presentation.label}
+        </Tag>
+      </Tooltip>
+    );
+  };
+
+  const renderResourceType = (type: string) => {
+    const presentation = getAuditResourcePresentation(type);
+    return (
+      <Tooltip title={type}>
+        <Tag color={presentation.color}>
+          {presentation.labelKey ? t(presentation.labelKey) : 
presentation.label}
+        </Tag>
+      </Tooltip>
+    );
+  };
+
+  const renderResult = (result: string) => {
+    const presentation = getAuditResultPresentation(result);
+    return (
+      <Tooltip title={result}>
+        <Tag color={presentation.color}>
+          {presentation.labelKey ? t(presentation.labelKey) : 
presentation.label}
+        </Tag>
+      </Tooltip>
+    );
+  };
+
+  const renderDetail = (detail: string | null | undefined) => {
+    const tokens = parseAuditDetail(detail);
+    if (tokens.length === 0) return <Text type="secondary">-</Text>;
+    if (tokens.length === 1 && !tokens[0].label) {
+      return (
+        <Text ellipsis={{ tooltip: tokens[0].value }} style={{ maxWidth: 420 
}}>
+          {tokens[0].value}
+        </Text>
+      );
+    }
+    return (
+      <Space size={[4, 4]} wrap>
+        {tokens.map((token) => (
+          <Tag key={`${token.label}:${token.value}`} style={{ marginInlineEnd: 
0 }}>
+            {token.label}: {token.value}
+          </Tag>
+        ))}
+      </Space>
+    );
+  };
+
   const handleCleanup = async () => {
     try {
       await cleanupAuditLogs(cleanupDays);
@@ -249,15 +303,16 @@ const AuditPage: React.FC = () => {
       width: 190,
       align: 'center',
       sorter: (a, b) => (a.operationType ?? '').localeCompare(b.operationType 
?? ''),
-      render: (type: string) => <Tag>{formatFilterLabel(type)}</Tag>,
+      render: renderOperationType,
     },
     {
       title: t('audit.resourceType'),
       dataIndex: 'resourceType',
-      width: 120,
+      width: 150,
       ellipsis: true,
       align: 'right',
       sorter: (a, b) => (a.resourceType ?? '').localeCompare(b.resourceType ?? 
''),
+      render: renderResourceType,
     },
     {
       title: t('audit.cluster'),
@@ -272,11 +327,17 @@ const AuditPage: React.FC = () => {
       width: 200,
       ellipsis: true,
       align: 'center',
+      render: (_: string, record) => (
+        <Tooltip title={describeAuditRecord(record, t)}>
+          <span>{record.target || '-'}</span>
+        </Tooltip>
+      ),
     },
     {
       title: t('audit.detail'),
       dataIndex: 'detail',
       ellipsis: true,
+      render: renderDetail,
     },
     {
       title: t('audit.result'),
@@ -284,9 +345,7 @@ const AuditPage: React.FC = () => {
       width: 80,
       align: 'center',
       sorter: (a, b) => (a.result ?? '').localeCompare(b.result ?? ''),
-      render: (result: string) => (
-        <Tag color={resultColor(result)}>{formatFilterLabel(result)}</Tag>
-      ),
+      render: renderResult,
     },
     {
       title: t('audit.error'),
@@ -323,10 +382,13 @@ const AuditPage: React.FC = () => {
               setPage(1);
               setSelectedType(value);
             }}
-            options={filterOptions.operationTypes.map((value) => ({
-              label: formatFilterLabel(value),
-              value,
-            }))}
+            options={filterOptions.operationTypes.map((value) => {
+              const presentation = getAuditOperationPresentation(value);
+              return {
+                label: presentation.labelKey ? t(presentation.labelKey) : 
presentation.label,
+                value,
+              };
+            })}
           />
           <Select
             aria-label={t('audit.resourceType')}
@@ -338,10 +400,13 @@ const AuditPage: React.FC = () => {
               setPage(1);
               setSelectedResourceType(value);
             }}
-            options={filterOptions.resourceTypes.map((value) => ({
-              label: formatFilterLabel(value),
-              value,
-            }))}
+            options={filterOptions.resourceTypes.map((value) => {
+              const presentation = getAuditResourcePresentation(value);
+              return {
+                label: presentation.labelKey ? t(presentation.labelKey) : 
presentation.label,
+                value,
+              };
+            })}
           />
           <Select
             aria-label={t('audit.cluster')}
@@ -372,10 +437,13 @@ const AuditPage: React.FC = () => {
             style={{ width: 120 }}
             options={[
               { label: t('common.all'), value: 'all' },
-              ...filterOptions.results.map((value) => ({
-                label: formatFilterLabel(value),
-                value,
-              })),
+              ...filterOptions.results.map((value) => {
+                const presentation = getAuditResultPresentation(value);
+                return {
+                  label: presentation.labelKey ? t(presentation.labelKey) : 
presentation.label,
+                  value,
+                };
+              }),
             ]}
           />
         </Flex>
diff --git a/web/src/pages/ops/auditPresentation.ts 
b/web/src/pages/ops/auditPresentation.ts
new file mode 100644
index 000000000..1980ce7c6
--- /dev/null
+++ b/web/src/pages/ops/auditPresentation.ts
@@ -0,0 +1,292 @@
+/*
+ * 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 type { AuditRecord } from '../../api/ops';
+
+export type AuditOperationCategory =
+  | 'metadata'
+  | 'proxy'
+  | 'messaging'
+  | 'cluster'
+  | 'security'
+  | 'settings'
+  | 'alerts'
+  | 'instance'
+  | 'certificate'
+  | 'other';
+
+export interface AuditPresentation {
+  /** English fallback when no i18n resolver is available. */
+  label: string;
+  color: string;
+  /** Translation key resolved via the language context at render time. */
+  labelKey?: string;
+}
+
+export interface AuditOperationPresentation extends AuditPresentation {
+  category: AuditOperationCategory;
+}
+
+export interface AuditDetailToken {
+  label: string;
+  value: string;
+}
+
+const operationPresentation: Record<string, AuditOperationPresentation> = {
+  CREATE_TOPIC: { label: 'Create Topic', color: 'blue', category: 'metadata' },
+  UPDATE_TOPIC: { label: 'Update Topic', color: 'cyan', category: 'metadata' },
+  DELETE_TOPIC: { label: 'Delete Topic', color: 'volcano', category: 
'metadata' },
+
+  CREATE_GROUP: { label: 'Create Group', color: 'blue', category: 'metadata' },
+  UPDATE_GROUP: { label: 'Update Group', color: 'cyan', category: 'metadata' },
+  DELETE_GROUP: { label: 'Delete Group', color: 'volcano', category: 
'metadata' },
+  RESET_OFFSET: { label: 'Reset Offset', color: 'gold', category: 'metadata' },
+
+  ADD_PROXY_ADDRESS: { label: 'Add Proxy Address', color: 'geekblue', 
category: 'proxy' },
+  REMOVE_PROXY_ADDRESS: { label: 'Remove Proxy Address', color: 'volcano', 
category: 'proxy' },
+  RELOAD_PROXY_CONFIG: { label: 'Reload Proxy Config', color: 'purple', 
category: 'proxy' },
+
+  SEND_MESSAGE: { label: 'Send Message', color: 'green', category: 'messaging' 
},
+  RESEND_DLQ: { label: 'Resend DLQ', color: 'green', category: 'messaging' },
+  DIRECT_CONSUME_MESSAGE: {
+    label: 'Direct Consume Message',
+    color: 'green',
+    category: 'messaging',
+  },
+
+  UPDATE_BROKER_CONFIG: { label: 'Update Broker Config', color: 'purple', 
category: 'cluster' },
+  UPDATE_CLUSTER_CONFIG: { label: 'Update Cluster Config', color: 'purple', 
category: 'cluster' },
+  RESTART_BROKER: { label: 'Restart Broker', color: 'orange', category: 
'cluster' },
+
+  CREATE_ACL_RULE: { label: 'Create ACL Rule', color: 'blue', category: 
'security' },
+  UPDATE_ACL_RULE: { label: 'Update ACL Rule', color: 'cyan', category: 
'security' },
+  DELETE_ACL_RULE: { label: 'Delete ACL Rule', color: 'volcano', category: 
'security' },
+  CREATE_ACL_USER: { label: 'Create ACL User', color: 'blue', category: 
'security' },
+  UPDATE_ACL_USER: { label: 'Update ACL User', color: 'cyan', category: 
'security' },
+  DELETE_ACL_USER: { label: 'Delete ACL User', color: 'volcano', category: 
'security' },
+  UPSERT_PLAIN_ACCESS_CONFIG: {
+    label: 'Upsert Plain Access Config',
+    color: 'purple',
+    category: 'security',
+  },
+
+  UPDATE_SETTINGS: { label: 'Update Settings', color: 'purple', category: 
'settings' },
+  CREATE_DATA_SOURCE: { label: 'Create Data Source', color: 'blue', category: 
'settings' },
+  UPDATE_DATA_SOURCE: { label: 'Update Data Source', color: 'cyan', category: 
'settings' },
+  DELETE_DATA_SOURCE: { label: 'Delete Data Source', color: 'volcano', 
category: 'settings' },
+  CREATE_CLOUD_CREDENTIAL: {
+    label: 'Create Cloud Credential',
+    color: 'blue',
+    category: 'settings',
+  },
+  UPDATE_CLOUD_CREDENTIAL: {
+    label: 'Update Cloud Credential',
+    color: 'cyan',
+    category: 'settings',
+  },
+  DELETE_CLOUD_CREDENTIAL: {
+    label: 'Delete Cloud Credential',
+    color: 'volcano',
+    category: 'settings',
+  },
+
+  CREATE_ALERT_RULE: { label: 'Create Alert Rule', color: 'blue', category: 
'alerts' },
+  UPDATE_ALERT_RULE: { label: 'Update Alert Rule', color: 'cyan', category: 
'alerts' },
+  TOGGLE_ALERT_RULE: { label: 'Toggle Alert Rule', color: 'gold', category: 
'alerts' },
+  DELETE_ALERT_RULE: { label: 'Delete Alert Rule', color: 'volcano', category: 
'alerts' },
+  ACKNOWLEDGE_SYSTEM_ALERT: {
+    label: 'Acknowledge System Alert',
+    color: 'green',
+    category: 'alerts',
+  },
+  CLEAR_ACKNOWLEDGED_SYSTEM_ALERTS: {
+    label: 'Clear Acknowledged Alerts',
+    color: 'volcano',
+    category: 'alerts',
+  },
+
+  CREATE_INSTANCE: { label: 'Create Instance', color: 'blue', category: 
'instance' },
+  UPDATE_INSTANCE: { label: 'Update Instance', color: 'cyan', category: 
'instance' },
+  DELETE_INSTANCE: { label: 'Delete Instance', color: 'volcano', category: 
'instance' },
+
+  CREATE_K8S_CERTIFICATE: {
+    label: 'Create K8s Certificate',
+    color: 'blue',
+    category: 'certificate',
+  },
+  UPDATE_K8S_CERTIFICATE: {
+    label: 'Update K8s Certificate',
+    color: 'cyan',
+    category: 'certificate',
+  },
+  RENEW_K8S_CERTIFICATE: {
+    label: 'Renew K8s Certificate',
+    color: 'green',
+    category: 'certificate',
+  },
+  DELETE_K8S_CERTIFICATE: {
+    label: 'Delete K8s Certificate',
+    color: 'volcano',
+    category: 'certificate',
+  },
+};
+
+const resourcePresentation: Record<string, AuditPresentation> = {
+  TOPIC: { label: 'Topic', color: 'blue' },
+  GROUP: { label: 'Consumer Group', color: 'geekblue' },
+  CONSUMER_GROUP: { label: 'Consumer Group', color: 'geekblue' },
+  MESSAGE: { label: 'Message', color: 'green' },
+  DLQ: { label: 'DLQ', color: 'green' },
+  PROXY: { label: 'Proxy', color: 'purple' },
+  BROKER: { label: 'Broker', color: 'orange' },
+  CLUSTER: { label: 'Cluster', color: 'orange' },
+  INSTANCE: { label: 'Instance', color: 'cyan' },
+  ACL_RULE: { label: 'ACL Rule', color: 'red' },
+  ACL_USER: { label: 'ACL User', color: 'red' },
+  SETTINGS: { label: 'Settings', color: 'purple' },
+  METRICS_DATA_SOURCE: { label: 'Metrics Data Source', color: 'purple' },
+  CLOUD_CREDENTIAL: { label: 'Cloud Credential', color: 'cyan' },
+  ALERT_RULE: { label: 'Alert Rule', color: 'gold' },
+  SYSTEM_ALERT: { label: 'System Alert', color: 'gold' },
+  K8S_CERTIFICATE: { label: 'K8s Certificate', color: 'lime' },
+};
+
+const resultPresentation: Record<string, AuditPresentation> = {
+  SUCCESS: { label: 'Success', color: 'green' },
+  PARTIAL: { label: 'Partial', color: 'orange' },
+  FAILED: { label: 'Failed', color: 'red' },
+  FAILURE: { label: 'Failed', color: 'red' },
+};
+
+const categoryFallbackColor: Record<AuditOperationCategory, string> = {
+  metadata: 'blue',
+  proxy: 'purple',
+  messaging: 'green',
+  cluster: 'orange',
+  security: 'red',
+  settings: 'cyan',
+  alerts: 'gold',
+  instance: 'geekblue',
+  certificate: 'lime',
+  other: 'default',
+};
+
+export function normalizeAuditCode(code: string | null | undefined): string {
+  return (code ?? '').trim().toUpperCase();
+}
+
+export function formatAuditCode(code: string | null | undefined): string {
+  const normalized = normalizeAuditCode(code);
+  if (!normalized) return '-';
+  return normalized
+    .split('_')
+    .filter(Boolean)
+    .map((part) => part.charAt(0) + part.slice(1).toLowerCase())
+    .join(' ');
+}
+
+export function getAuditOperationPresentation(
+  operationType: string | null | undefined,
+): AuditOperationPresentation {
+  const normalized = normalizeAuditCode(operationType);
+  const known = operationPresentation[normalized];
+  if (known) {
+    return { ...known, labelKey: `audit.op.${normalized}` };
+  }
+  return {
+    label: formatAuditCode(normalized),
+    color: categoryFallbackColor.other,
+    category: 'other',
+  };
+}
+
+export function getAuditResourcePresentation(
+  resourceType: string | null | undefined,
+): AuditPresentation {
+  const normalized = normalizeAuditCode(resourceType);
+  const known = resourcePresentation[normalized];
+  if (known) {
+    return { ...known, labelKey: `audit.res.${normalized}` };
+  }
+  return { label: formatAuditCode(normalized), color: 'default' };
+}
+
+export function getAuditResultPresentation(result: string | null | undefined): 
AuditPresentation {
+  const normalized = normalizeAuditCode(result);
+  const known = resultPresentation[normalized];
+  if (known) {
+    const key = normalized === 'FAILURE' ? 'FAILED' : normalized;
+    return { ...known, labelKey: `audit.result.${key}` };
+  }
+  return { label: formatAuditCode(normalized), color: 'default' };
+}
+
+export function isControlPlaneAuditRecord(record: Pick<AuditRecord, 
'operationType'>): boolean {
+  const category = 
getAuditOperationPresentation(record.operationType).category;
+  return category === 'metadata' || category === 'proxy' || category === 
'cluster';
+}
+
+export function describeAuditRecord(
+  record: AuditRecord,
+  translate?: (key: string) => string,
+): string {
+  const operationPresentation = 
getAuditOperationPresentation(record.operationType);
+  const resourcePresentationValue = 
getAuditResourcePresentation(record.resourceType);
+  const operation =
+    translate && operationPresentation.labelKey
+      ? translate(operationPresentation.labelKey)
+      : operationPresentation.label;
+  const resource =
+    translate && resourcePresentationValue.labelKey
+      ? translate(resourcePresentationValue.labelKey)
+      : resourcePresentationValue.label;
+  const target = record.target?.trim() || '-';
+  const clusterId = record.clusterId?.trim();
+  if (translate) {
+    const parts = [operation, resource, target];
+    if (clusterId) {
+      parts.push(clusterId);
+    }
+    return parts.join(' · ');
+  }
+  return clusterId
+    ? `${operation} ${resource} ${target} in ${clusterId}`
+    : `${operation} ${resource} ${target}`;
+}
+
+export function parseAuditDetail(detail: string | null | undefined): 
AuditDetailToken[] {
+  const text = detail?.trim();
+  if (!text) return [];
+
+  const parts = text.split(/\s*,\s*/).filter(Boolean);
+  if (parts.length <= 1) return [{ label: '', value: text }];
+
+  const tokens = parts.map((part) => {
+    const separatorIndex = part.indexOf('=');
+    if (separatorIndex < 1) {
+      return null;
+    }
+    const label = part.slice(0, separatorIndex).trim();
+    const value = part.slice(separatorIndex + 1).trim();
+    return label && value ? { label, value } : null;
+  });
+
+  if (tokens.some((token) => token == null)) {
+    return [{ label: '', value: text }];
+  }
+  return tokens as AuditDetailToken[];
+}

Reply via email to