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[];
+}