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 ace3fb91 fix: instance-scoped hardening, mutation auditing and
validation (#1274, #1280, #1282, #1288, #1293, #1296, #1303, #1305, #1307,
#1309, #1311, #1319)
ace3fb91 is described below
commit ace3fb9100631e4e2fb49d2292f4a94b15f99806
Author: lizhimins <[email protected]>
AuthorDate: Mon Aug 10 12:57:56 2026 +0800
fix: instance-scoped hardening, mutation auditing and validation (#1274,
#1280, #1282, #1288, #1293, #1296, #1303, #1305, #1307, #1309, #1311, #1319)
* fix: reject non-SEND_OK topic test messages
Signed-off-by: liuhy <[email protected]>
* fix: surface message trace query failures
Signed-off-by: liuhy <[email protected]>
* fix: require a topic for message ID queries
* fix: reject blank message trace identifiers
* fix: clear stale DLQ state when switching instances
Signed-off-by: liuhy <[email protected]>
* fix: handle client instance bootstrap failures
Signed-off-by: liuhy <[email protected]>
* fix: scope producer topics by selected instance
Signed-off-by: liuhy <[email protected]>
* fix: require instance IDs for client queries
* fix: ignore stale dashboard responses
Signed-off-by: liuhy <[email protected]>
* test: cover dashboard instance response ordering
Signed-off-by: liuhy <[email protected]>
* fix: initialize the H2 schema for development
* fix: reject cloud instances from remoting admin resolution
* feat: audit cloud credential mutations
* feat: audit managed instance mutations
* fix: normalize managed instance names
* feat: audit ACL mutations
* fix: clarify that ACL records are Studio-local
* feat: audit K8s certificate mutations
* fix: clarify that K8s certificate records are local
* feat: audit alert mutations
* fix: exclude disabled alert rules from export
* fix: validate runtime Topic and Group names
* fix: adapt batch to studio conventions and stabilize producer instance
switch
- isolate audit write failures (try/catch + warn) in
Instance/K8sCert/Acl/Alert/CloudCredential services, matching the control-plane
audit isolation precedent
- replace private isBlank/JDK blank checks with Spring StringUtils in
MetadataService and ClientService
- Producer page: clear stale topic/group state in the instance change
handler instead of synchronously in an effect (also fixes antd Select breakage
across remounts in tests)
- Producer test: scope stale-state assertions to the render container (antd
keeps closed dropdown portals in document.body)
---------
Signed-off-by: liuhy <[email protected]>
Co-authored-by: liuhy <[email protected]>
---
.../cluster/broker/RuntimeAdminClientResolver.java | 12 ++-
.../studio/cluster/client/ClientService.java | 11 +-
.../studio/cluster/k8s/K8sCertService.java | 31 +++++-
.../rocketmq/studio/instance/InstanceService.java | 43 ++++++--
.../rocketmq/studio/instance/acl/AclService.java | 41 ++++++-
.../studio/instance/message/MessageService.java | 6 ++
.../studio/instance/topic/MetadataService.java | 33 +++---
.../rocketmq/studio/ops/alert/AlertService.java | 43 +++++++-
.../provider/apache/RocketMQAdminClientImpl.java | 8 ++
.../provider/apache/RocketMQMessageProvider.java | 3 +
.../credential/CloudCredentialService.java | 27 ++++-
server/src/main/resources/application-dev.yml | 4 +
server/src/main/resources/db/schema.sql | 14 ++-
.../rocketmq/studio/StudioApplicationTest.java | 20 +++-
.../broker/RuntimeAdminClientResolverTest.java | 21 ++++
.../studio/cluster/client/ClientServiceTest.java | 20 +++-
.../studio/cluster/k8s/K8sCertServiceTest.java | 18 +++-
.../studio/instance/InstanceServiceTest.java | 35 ++++++
.../studio/instance/acl/AclServiceTest.java | 20 ++++
.../instance/message/MessageServiceTest.java | 27 +++--
.../studio/instance/topic/MetadataServiceTest.java | 21 ++++
.../ops/alert/AlertServiceDefaultRulesTest.java | 8 +-
.../studio/ops/alert/AlertServiceTest.java | 70 +++++++++++-
.../apache/RocketMQAdminClientImplTest.java | 49 +++++++++
.../apache/RocketMQMessageProviderTest.java | 13 +++
.../credential/CloudCredentialServiceTest.java | 14 +++
web/src/api/producer.test.ts | 11 +-
web/src/api/producer.ts | 6 +-
web/src/i18n/translations.ts | 8 ++
.../pages/cluster/__tests__/ClientsPage.test.tsx | 28 +++++
.../pages/cluster/__tests__/K8sCertsPage.test.tsx | 8 ++
web/src/pages/cluster/certs.tsx | 9 ++
web/src/pages/cluster/clients.tsx | 40 +++++--
.../pages/home/__tests__/DashboardPage.test.tsx | 118 +++++++++++++++++++++
web/src/pages/home/dashboard.tsx | 17 ++-
web/src/pages/instance/__tests__/AclPage.test.tsx | 8 ++
web/src/pages/instance/__tests__/DLQPage.test.tsx | 46 ++++++++
.../pages/instance/__tests__/MessagePage.test.tsx | 22 +++-
web/src/pages/instance/acl.tsx | 10 ++
web/src/pages/instance/dlq.tsx | 17 ++-
web/src/pages/instance/message.tsx | 51 ++++++---
web/src/pages/studio/Producer.tsx | 22 +++-
web/src/pages/studio/__tests__/Producer.test.tsx | 64 ++++++++++-
43 files changed, 994 insertions(+), 103 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RuntimeAdminClientResolver.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RuntimeAdminClientResolver.java
index 5acead9a..2bd87db7 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RuntimeAdminClientResolver.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RuntimeAdminClientResolver.java
@@ -7,6 +7,7 @@
package org.apache.rocketmq.studio.cluster.broker;
import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.instance.InstanceVO;
@@ -29,7 +30,7 @@ public class RuntimeAdminClientResolver {
}
public String resolveEndpoint(String instanceId) {
- InstanceVO instance = resolveInstance(instanceId);
+ InstanceVO instance =
requireApacheInstance(resolveInstance(instanceId));
if (!StringUtils.hasText(instance.getEndpoint())) {
throw new BusinessException(400, "Instance has no endpoint: " +
instanceId);
}
@@ -41,9 +42,18 @@ public class RuntimeAdminClientResolver {
}
public <T> T execute(InstanceVO instance, MqAdminExtFactory.AdminAction<T>
action) {
+ requireApacheInstance(instance);
if (instance == null || !StringUtils.hasText(instance.getEndpoint())) {
throw new BusinessException(400, "Instance endpoint is required");
}
return adminFactory.execute(instance.getEndpoint().trim(), null,
action);
}
+
+ private InstanceVO requireApacheInstance(InstanceVO instance) {
+ if (instance != null && instance.getVendor() != null &&
instance.getVendor() != InstanceVendor.APACHE) {
+ throw new BusinessException(400,
+ "Runtime AdminClient only supports Apache instances: " +
instance.getId());
+ }
+ return instance;
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ClientService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ClientService.java
index ed9723e4..9d04ebcf 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ClientService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/client/ClientService.java
@@ -18,7 +18,9 @@ package org.apache.rocketmq.studio.cluster.client;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
import java.util.List;
@@ -31,7 +33,14 @@ public class ClientService {
public List<ClientConnectionVO> listConnections(String instanceId, String
clusterId, String type) {
log.info("Listing client connections, instanceId={}, clusterId={},
type={}", instanceId, clusterId, type);
- return clientProvider.findConnections(normalizeFilter(instanceId),
normalizeFilter(clusterId), normalizeFilter(type));
+ return clientProvider.findConnections(requireInstanceId(instanceId),
normalizeFilter(clusterId), normalizeFilter(type));
+ }
+
+ private String requireInstanceId(String instanceId) {
+ if (!StringUtils.hasText(instanceId)) {
+ throw new BusinessException(400, "instanceId is required");
+ }
+ return instanceId.trim();
}
private String normalizeFilter(String value) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
index 6ca6debf..b13f9804 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.cluster.k8s;
import org.apache.rocketmq.studio.common.domain.enums.CertStatus;
import org.apache.rocketmq.studio.common.domain.enums.CertType;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -38,15 +39,17 @@ public class K8sCertService {
private static final int EXPIRING_THRESHOLD_DAYS = 30;
private final K8sCertRepository k8sCertRepository;
+ private final OperationAuditService operationAuditService;
private final Clock clock;
@Autowired
- public K8sCertService(K8sCertRepository k8sCertRepository) {
- this(k8sCertRepository, Clock.systemDefaultZone());
+ public K8sCertService(K8sCertRepository k8sCertRepository,
OperationAuditService operationAuditService) {
+ this(k8sCertRepository, operationAuditService,
Clock.systemDefaultZone());
}
- K8sCertService(K8sCertRepository k8sCertRepository, Clock clock) {
+ K8sCertService(K8sCertRepository k8sCertRepository, OperationAuditService
operationAuditService, Clock clock) {
this.k8sCertRepository = k8sCertRepository;
+ this.operationAuditService = operationAuditService;
this.clock = clock;
}
@@ -82,6 +85,7 @@ public class K8sCertService {
cert.setUpdatedAt(now);
K8sCertVO saved = k8sCertRepository.save(cert);
+ auditCertificate("CREATE_K8S_CERTIFICATE", saved);
log.info("K8s certificate created: {} (id={})", saved.getName(),
saved.getId());
return saved;
}
@@ -115,6 +119,7 @@ public class K8sCertService {
updated.setUpdatedAt(now);
K8sCertVO saved =
k8sCertRepository.save(refreshExpirationState(updated, now));
+ auditCertificate("UPDATE_K8S_CERTIFICATE", saved);
log.info("K8s certificate updated: {} (id={})", saved.getName(),
saved.getId());
return saved;
}
@@ -136,6 +141,7 @@ public class K8sCertService {
renewed.setUpdatedAt(now);
K8sCertVO saved = k8sCertRepository.save(renewed);
+ auditCertificate("RENEW_K8S_CERTIFICATE", saved);
log.info("K8s certificate renewed: {} (id={}), new expiry: {}",
saved.getName(), saved.getId(), notAfter);
return saved;
}
@@ -146,6 +152,8 @@ public class K8sCertService {
k8sCertRepository.findById(command.getId())
.orElseThrow(() -> new BusinessException(404, "Certificate not
found: " + command.getId()));
k8sCertRepository.deleteById(command.getId());
+ recordAudit("DELETE_K8S_CERTIFICATE", "K8S_CERTIFICATE",
command.getId(), null,
+ null);
log.info("K8s certificate deleted: {}", command.getId());
}
@@ -192,4 +200,21 @@ public class K8sCertService {
copy.setUpdatedAt(cert.getUpdatedAt());
return copy;
}
+
+ private void auditCertificate(String operation, K8sCertVO certificate) {
+ recordAudit(operation, "K8S_CERTIFICATE", certificate.getId(), null,
+ "name=" + certificate.getName() + ", namespace=" +
certificate.getNamespace()
+ + ", cluster=" + certificate.getCluster());
+ }
+
+ private void recordAudit(String operation, String resourceType, String
resourceName,
+ String clusterId, String detail) {
+ try {
+ operationAuditService.record(operation, resourceType,
resourceName, clusterId, detail, "SUCCESS", null);
+ } 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/InstanceService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index 753cc464..4971f38f 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -22,6 +22,7 @@ import org.springframework.util.StringUtils;
import
org.apache.rocketmq.studio.provider.credential.CloudCredentialRepository;
import org.apache.rocketmq.studio.provider.credential.CloudCredentialVO;
import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -45,6 +46,7 @@ public class InstanceService {
private final CloudCredentialRepository cloudCredentialRepository;
private final InstanceProviderRegistry providerRegistry;
private final MqAdminExtFactory adminFactory;
+ private final OperationAuditService operationAuditService;
public List<InstanceVO> listInstances(InstanceType type, String search) {
log.debug("Listing instances, type={}, search={}", type, search);
@@ -96,14 +98,15 @@ public class InstanceService {
instance.setId(UUID.randomUUID().toString());
instance.setCreatedAt(LocalDateTime.now());
instance.setUpdatedAt(LocalDateTime.now());
- return instanceRepository.save(instance);
+ InstanceVO saved = instanceRepository.save(instance);
+ recordAudit("CREATE_INSTANCE", "INSTANCE", saved.getId(), null,
+ instanceAuditDetail(saved));
+ return saved;
}
private void createApacheInstance(InstanceVO instance) {
instance.setVendor(InstanceVendor.APACHE);
- if (instance.getName() == null || instance.getName().isBlank()) {
- throw new BusinessException(400, "InstanceVO name is required");
- }
+ instance.setName(requireInstanceName(instance.getName()));
instance.setEndpoint(requireValidEndpoint(instance.getEndpoint()));
if (instance.getType() == null) {
throw new BusinessException(400, "InstanceVO type is required");
@@ -131,10 +134,11 @@ public class InstanceService {
}
CloudInstanceDetailVO detail =
providerRegistry.catalogFor(InstanceVendor.ALIYUN)
.getCloudInstance(instance.getCredentialId(),
instance.getRegionId(), instance.getCloudInstanceId());
- if (instance.getName() == null || instance.getName().isBlank()) {
+ if (!StringUtils.hasText(instance.getName())) {
instance.setName(detail.getInstanceName() != null &&
!detail.getInstanceName().isBlank()
? detail.getInstanceName() : detail.getInstanceId());
}
+ instance.setName(requireInstanceName(instance.getName()));
instance.setType(InstanceType.PROXY);
instance.setEndpoint(resolveEndpoint(detail));
}
@@ -176,6 +180,13 @@ public class InstanceService {
return normalized;
}
+ private String requireInstanceName(String name) {
+ if (!StringUtils.hasText(name)) {
+ throw new BusinessException(400, "InstanceVO name is required");
+ }
+ return name.trim();
+ }
+
public InstanceVO updateInstance(InstanceVO instance) {
requireInstance(instance);
log.info("Updating instance: {}", instance.getId());
@@ -194,7 +205,7 @@ public class InstanceService {
InstanceVO updated = copyOf(existing);
boolean cloudInstance = existing.getVendor() != null &&
existing.getVendor() != InstanceVendor.APACHE;
if (instance.getName() != null) {
- updated.setName(instance.getName());
+ updated.setName(requireInstanceName(instance.getName()));
}
if (!cloudInstance) {
if (instance.getType() != null) {
@@ -211,6 +222,8 @@ public class InstanceService {
InstanceVO saved = instanceRepository.save(updated);
releaseApacheEndpointIfUnused(existing, saved.getEndpoint());
+ recordAudit("UPDATE_INSTANCE", "INSTANCE", saved.getId(), null,
+ instanceAuditDetail(saved));
return saved;
}
@@ -235,6 +248,8 @@ public class InstanceService {
}
instanceRepository.deleteById(id);
releaseApacheEndpointIfUnused(existing, null);
+ recordAudit("DELETE_INSTANCE", "INSTANCE", id, null,
+ instanceAuditDetail(existing));
}
private void requireInstance(InstanceVO instance) {
@@ -243,6 +258,11 @@ public class InstanceService {
}
}
+ private String instanceAuditDetail(InstanceVO instance) {
+ InstanceVendor vendor = instance.getVendor() == null ?
InstanceVendor.APACHE : instance.getVendor();
+ return "name=" + instance.getName() + ", vendor=" + vendor + ", type="
+ instance.getType();
+ }
+
private InstanceVO copyOf(InstanceVO instance) {
InstanceVO copy = InstanceVO.builder()
.name(instance.getName())
@@ -290,4 +310,15 @@ public class InstanceService {
String normalizedEndpoint =
MqAdminExtFactory.normalizeNamesrvAddr(endpoint);
return normalizedEndpoint.isEmpty() ? null : normalizedEndpoint;
}
+
+ private void recordAudit(String operation, String resourceType, String
resourceName,
+ String clusterId, String detail) {
+ try {
+ operationAuditService.record(operation, resourceType,
resourceName, clusterId, detail, "SUCCESS", null);
+ } 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/acl/AclService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
index c3ada9d7..1916f62b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
@@ -20,6 +20,7 @@ import org.springframework.util.StringUtils;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.CredentialUtils;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -34,6 +35,7 @@ import java.util.UUID;
public class AclService {
private final AclRepository aclRepository;
+ private final OperationAuditService operationAuditService;
public List<AclRuleVO> listRules(String clusterId, String principal) {
@@ -52,7 +54,9 @@ public class AclService {
}
rule.setId(UUID.randomUUID().toString());
rule.setCreatedAt(LocalDateTime.now());
- return aclRepository.saveRule(rule);
+ AclRuleVO saved = aclRepository.saveRule(rule);
+ auditRule("CREATE_ACL_RULE", saved);
+ return saved;
}
public AclRuleVO updateRule(AclRuleVO rule) {
@@ -60,8 +64,10 @@ public class AclService {
throw new BusinessException(400, "ACL rule id is required");
}
log.info("Updating ACL rule id={}, principal={}", rule.getId(),
rule.getPrincipal());
- return aclRepository.replaceRule(rule)
+ AclRuleVO saved = aclRepository.replaceRule(rule)
.orElseThrow(() -> new BusinessException(404, "ACL rule not
found: " + rule.getId()));
+ auditRule("UPDATE_ACL_RULE", saved);
+ return saved;
}
public void deleteRule(String id) {
@@ -69,6 +75,7 @@ public class AclService {
if (!aclRepository.deleteRule(id)) {
throw new BusinessException(404, "ACL rule not found: " + id);
}
+ recordAudit("DELETE_ACL_RULE", "ACL_RULE", id, null, null);
}
@@ -89,7 +96,9 @@ public class AclService {
user.setAccessKey(UUID.randomUUID().toString().replace("-", ""));
user.setSecretKey(UUID.randomUUID().toString().replace("-", ""));
user.setCreatedAt(LocalDateTime.now());
- return aclRepository.saveUser(user);
+ AclUserVO saved = aclRepository.saveUser(user);
+ auditUser("CREATE_ACL_USER", saved);
+ return saved;
}
public AclUserVO updateUser(UpdateAclUserDTO user) {
@@ -108,7 +117,9 @@ public class AclService {
.clusters(user.getClusters() == null ? existing.getClusters()
: user.getClusters())
.createdAt(existing.getCreatedAt())
.build();
- return maskCredentials(aclRepository.saveUser(merged));
+ AclUserVO saved = aclRepository.saveUser(merged);
+ auditUser("UPDATE_ACL_USER", saved);
+ return maskCredentials(saved);
}
public void deleteUser(String id) {
@@ -116,6 +127,7 @@ public class AclService {
if (!aclRepository.deleteUser(id)) {
throw new BusinessException(404, "ACL user not found: " + id);
}
+ recordAudit("DELETE_ACL_USER", "ACL_USER", id, null, null);
}
/**
@@ -143,4 +155,25 @@ public class AclService {
.build();
}
+ private void auditRule(String operation, AclRuleVO rule) {
+ recordAudit(operation, "ACL_RULE", rule.getId(), null,
+ "principal=" + rule.getPrincipal());
+ }
+
+ private void auditUser(String operation, AclUserVO user) {
+ recordAudit(operation, "ACL_USER", user.getId(), null,
+ "username=" + user.getUsername() + ", admin=" +
user.isAdmin());
+ }
+
+
+ private void recordAudit(String operation, String resourceType, String
resourceName,
+ String clusterId, String detail) {
+ try {
+ operationAuditService.record(operation, resourceType,
resourceName, clusterId, detail, "SUCCESS", null);
+ } 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/message/MessageService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
index b67b9557..5e5a7da5 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageService.java
@@ -45,6 +45,9 @@ public class MessageService {
}
public TraceRecordVO getMessageTrace(String instanceId, String msgId) {
+ if (!StringUtils.hasText(msgId)) {
+ throw new BusinessException(400, "msgId is required");
+ }
log.info("Getting message trace: msgId={}", msgId);
return providerRegistry.byInstanceId(instanceId)
.map(provider -> provider.getMessageTrace(instanceId, msgId))
@@ -58,6 +61,9 @@ public class MessageService {
if (hasKey && !hasTopic) {
throw new BusinessException(400, "topic is required when key is
specified");
}
+ if (hasMessageId && !hasTopic) {
+ throw new BusinessException(400, "topic is required when msgId is
specified");
+ }
if (!hasTopic && !hasMessageId) {
throw new BusinessException(400, "topic or msgId is required");
}
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 d67ace2a..31b70cb7 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
@@ -20,6 +20,7 @@ import org.apache.rocketmq.studio.provider.apache.AdminClient;
import org.apache.rocketmq.studio.provider.apache.MetadataProvider;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.util.StringUtils;
import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO;
import org.apache.rocketmq.studio.instance.group.QueueProgressVO;
import org.apache.rocketmq.studio.instance.group.SubscriptionEntryVO;
@@ -49,7 +50,7 @@ public class MetadataService {
}
public List<TopicVO> listTopics(String instanceId, String clusterId,
String type, String search) {
- if (isBlank(instanceId) && !isBlank(clusterId)) {
+ if (!StringUtils.hasText(instanceId) &&
StringUtils.hasText(clusterId)) {
// legacy cluster-scoped read kept for AI tool handlers
return metadataProvider.listTopics(
normalizeFilter(clusterId), normalizeFilter(type),
normalizeFilter(search));
@@ -84,11 +85,12 @@ public class MetadataService {
}
public List<BrokerRouteVO> getTopicRoutes(String instanceId, String name) {
+ String topicName = requireName(name, "topic name");
if (resolve(instanceId).vendor() != InstanceVendor.APACHE) {
// broker routing does not apply to serverless cloud instances
return List.of();
}
- return metadataProvider.getTopicRoutes(instanceId, name);
+ return metadataProvider.getTopicRoutes(instanceId, topicName);
}
@@ -97,7 +99,8 @@ public class MetadataService {
}
public List<TopicConsumerVO> getTopicConsumers(String instanceId, String
name) {
- return resolve(instanceId).getTopicConsumers(instanceId, name);
+ String topicName = requireName(name, "topic name");
+ return resolve(instanceId).getTopicConsumers(instanceId, topicName);
}
@@ -117,7 +120,7 @@ public class MetadataService {
}
public List<ConsumerGroupVO> listConsumerGroups(String instanceId, String
clusterId, String search) {
- if (isBlank(instanceId) && !isBlank(clusterId)) {
+ if (!StringUtils.hasText(instanceId) &&
StringUtils.hasText(clusterId)) {
return
metadataProvider.listConsumerGroups(normalizeFilter(clusterId),
normalizeFilter(search));
}
return resolve(instanceId).listConsumerGroups(instanceId,
normalizeFilter(search));
@@ -129,10 +132,11 @@ public class MetadataService {
}
public ConsumerGroupVO getConsumerGroup(String instanceId, String name) {
+ String groupName = requireName(name, "consumer group name");
if (resolve(instanceId).vendor() != InstanceVendor.APACHE) {
throw new BusinessException(501, "Consumer group detail is not
supported for cloud instances");
}
- return adminClient.getConsumerGroup(instanceId, name);
+ return adminClient.getConsumerGroup(instanceId, groupName);
}
@@ -141,7 +145,8 @@ public class MetadataService {
}
public List<QueueProgressVO> getGroupProgress(String instanceId, String
name) {
- return resolve(instanceId).getGroupProgress(instanceId, name);
+ String groupName = requireName(name, "consumer group name");
+ return resolve(instanceId).getGroupProgress(instanceId, groupName);
}
@@ -150,7 +155,8 @@ public class MetadataService {
}
public List<SubscriptionEntryVO> getGroupSubscriptions(String instanceId,
String name) {
- return resolve(instanceId).getGroupSubscriptions(instanceId, name);
+ String groupName = requireName(name, "consumer group name");
+ return resolve(instanceId).getGroupSubscriptions(instanceId,
groupName);
}
@@ -187,12 +193,8 @@ public class MetadataService {
.orElseGet(() ->
providerRegistry.forVendor(InstanceVendor.APACHE));
}
- private static boolean isBlank(String value) {
- return value == null || value.isBlank();
- }
-
private String normalizeFilter(String value) {
- return value == null || value.isBlank() ? null : value.trim();
+ return !StringUtils.hasText(value) ? null : value.trim();
}
private void requireTopic(TopicVO topic) {
@@ -206,4 +208,11 @@ public class MetadataService {
throw new BusinessException(400, "Topic send message request is
required");
}
}
+
+ private String requireName(String value, String fieldName) {
+ if (!StringUtils.hasText(value)) {
+ throw new BusinessException(400, fieldName + " is required");
+ }
+ return value.trim();
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index 053bd567..512371a9 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.ops.alert;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -34,6 +35,7 @@ public class AlertService {
private final AlertRepository alertRepository;
private final AlertRuleAssetService alertRuleAssetService;
+ private final OperationAuditService operationAuditService;
public List<AlertRuleVO> listRules() {
@@ -42,7 +44,9 @@ public class AlertService {
}
public String exportPrometheusRulesYaml() {
- List<AlertRuleVO> rules = alertRepository.findAllRules();
+ List<AlertRuleVO> rules = alertRepository.findAllRules().stream()
+ .filter(AlertRuleVO::isEnabled)
+ .toList();
List<PrometheusAlertRule> prometheusRules = rules.isEmpty()
? defaultPrometheusRules()
: rules.stream().map(this::toPrometheusRule).toList();
@@ -83,7 +87,9 @@ public class AlertService {
}
log.info("Creating alert rule: {}", rule.getName());
rule.setId(UUID.randomUUID().toString());
- return alertRepository.saveRule(rule);
+ AlertRuleVO saved = alertRepository.saveRule(rule);
+ auditRule("CREATE_ALERT_RULE", saved, null);
+ return saved;
}
@@ -97,6 +103,7 @@ public class AlertService {
if (!alertRepository.replaceRule(rule)) {
throw ruleNotFound(id);
}
+ auditRule("UPDATE_ALERT_RULE", rule, null);
return rule;
}
@@ -109,7 +116,9 @@ public class AlertService {
.findFirst()
.orElseThrow(() -> new
org.apache.rocketmq.studio.common.exception.BusinessException(404, "Alert rule
not found: " + id));
rule.setEnabled(enabled);
- return alertRepository.saveRule(rule);
+ AlertRuleVO saved = alertRepository.saveRule(rule);
+ auditRule("TOGGLE_ALERT_RULE", saved, "enabled=" + enabled);
+ return saved;
}
@@ -118,6 +127,7 @@ public class AlertService {
if (!alertRepository.deleteRule(id)) {
throw ruleNotFound(id);
}
+ recordAudit("DELETE_ALERT_RULE", "ALERT_RULE", id, null, null);
}
@@ -135,13 +145,19 @@ public class AlertService {
.findFirst()
.orElseThrow(() -> new
org.apache.rocketmq.studio.common.exception.BusinessException(404, "System
alert not found: " + id));
alert.setAcknowledged(true);
- return alertRepository.saveAlert(alert);
+ SystemAlertVO saved = alertRepository.saveAlert(alert);
+ recordAudit("ACKNOWLEDGE_SYSTEM_ALERT", "SYSTEM_ALERT", saved.getId(),
null,
+ "acknowledged=true");
+ return saved;
}
public int clearAcknowledged() {
log.info("Clearing acknowledged system alerts");
- return alertRepository.deleteAcknowledgedAlerts();
+ int deleted = alertRepository.deleteAcknowledgedAlerts();
+ recordAudit("CLEAR_ACKNOWLEDGED_SYSTEM_ALERTS", "SYSTEM_ALERT", null,
null,
+ "deleted=" + deleted);
+ return deleted;
}
private List<PrometheusAlertRule> defaultPrometheusRules() {
@@ -202,6 +218,12 @@ public class AlertService {
selector.append(label).append("=\"").append(escapeDoubleQuotedValue(value.trim())).append('"');
}
+ private void auditRule(String operation, AlertRuleVO rule, String detail) {
+ String auditDetail = detail == null ? "name=" + rule.getName() :
detail;
+ recordAudit(operation, "ALERT_RULE", rule.getId(), null,
+ auditDetail);
+ }
+
private String severity(AlertRuleVO rule) {
String severity = rule.getSeverity();
if (hasText(severity)) {
@@ -296,4 +318,15 @@ public class AlertService {
private BusinessException ruleNotFound(String id) {
return new BusinessException(404, "Alert rule not found: " + id);
}
+
+ private void recordAudit(String operation, String resourceType, String
resourceName,
+ String clusterId, String detail) {
+ try {
+ operationAuditService.record(operation, resourceType,
resourceName, clusterId, detail, "SUCCESS", null);
+ } 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/provider/apache/RocketMQAdminClientImpl.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
index 2f59fb11..e7a89ea6 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.provider.apache;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
+import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.common.TopicConfig;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
@@ -335,6 +336,10 @@ public class RocketMQAdminClientImpl implements
AdminClient {
}
SendResult sendResult = producer.send(msg);
+ if (sendResult == null || sendResult.getSendStatus() !=
SendStatus.SEND_OK) {
+ String status = sendResult == null ? "null" :
String.valueOf(sendResult.getSendStatus());
+ throw new BusinessException(502, "Message send did not
succeed: " + status);
+ }
// The message is already delivered by now; an audit write failure
must not turn a
// successful send into an error, or callers would retry and
duplicate the message.
@@ -346,6 +351,9 @@ public class RocketMQAdminClientImpl implements AdminClient
{
.sendTime(System.currentTimeMillis())
.offsetMsgId(sendResult.getOffsetMsgId())
.build();
+ } catch (BusinessException e) {
+ recordAudit("SEND_MESSAGE", request.getTopic(), e.getMessage(),
"FAILED");
+ throw e;
} catch (Exception e) {
recordAudit("SEND_MESSAGE", request.getTopic(), e.getMessage(),
"FAILED");
throw new BusinessException(500, "Failed to send message: " +
e.getMessage());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
index 1ef0d70d..420a796a 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
@@ -263,8 +263,11 @@ public class RocketMQMessageProvider implements
MessageProvider {
parseTraceBody(traceMessage.getBody(), msgId, nodes,
consumerStatus);
}
}
+ } catch (BusinessException e) {
+ throw e;
} catch (Exception e) {
log.warn("Trace query for msgId={} failed: {}", msgId,
e.getMessage());
+ throw new BusinessException(502, "Failed to query message trace: "
+ e.getMessage());
}
recordTraceQuery(instanceId, msgId, null, nodes.size(),
consumerStatus.size());
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
index 10d7ca2a..0decc7ec 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
@@ -20,6 +20,7 @@ import org.springframework.util.StringUtils;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.CredentialUtils;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.provider.alibaba.AliyunClientFactory;
import org.springframework.stereotype.Service;
@@ -38,6 +39,7 @@ public class CloudCredentialService {
private final CloudCredentialRepository credentialRepository;
private final InstanceRepository instanceRepository;
private final AliyunClientFactory aliyunClientFactory;
+ private final OperationAuditService operationAuditService;
public List<CloudCredentialVO> listMasked() {
log.info("Listing cloud credentials (masked)");
@@ -69,7 +71,10 @@ public class CloudCredentialService {
credential.setId(UUID.randomUUID().toString());
credential.setCreatedAt(LocalDateTime.now());
credential.setUpdatedAt(LocalDateTime.now());
- return maskAccessKey(credentialRepository.save(credential));
+ CloudCredentialVO saved = credentialRepository.save(credential);
+ recordAudit("CREATE_CLOUD_CREDENTIAL", "CLOUD_CREDENTIAL",
saved.getId(), null,
+ credentialAuditDetail(saved));
+ return maskAccessKey(saved);
}
public CloudCredentialVO update(UpdateCloudCredentialDTO request) {
@@ -94,6 +99,8 @@ public class CloudCredentialService {
existing.setUpdatedAt(LocalDateTime.now());
CloudCredentialVO saved = credentialRepository.save(existing);
invalidateAliyunClients(saved);
+ recordAudit("UPDATE_CLOUD_CREDENTIAL", "CLOUD_CREDENTIAL",
saved.getId(), null,
+ credentialAuditDetail(saved));
return maskAccessKey(saved);
}
@@ -109,6 +116,8 @@ public class CloudCredentialService {
}
credentialRepository.deleteById(id);
invalidateAliyunClients(existing);
+ recordAudit("DELETE_CLOUD_CREDENTIAL", "CLOUD_CREDENTIAL", id, null,
+ credentialAuditDetail(existing));
}
public CloudCredentialVO reveal(String id) {
@@ -131,9 +140,25 @@ public class CloudCredentialService {
masked.setUpdatedAt(credential.getUpdatedAt());
return masked;
}
+
+ private String credentialAuditDetail(CloudCredentialVO credential) {
+ return "name=" + credential.getName() + ", vendor=" +
credential.getVendor();
+ }
+
private void invalidateAliyunClients(CloudCredentialVO credential) {
if (credential.getVendor() ==
org.apache.rocketmq.studio.common.domain.enums.InstanceVendor.ALIYUN) {
aliyunClientFactory.invalidateCredential(credential.getId());
}
}
+
+ private void recordAudit(String operation, String resourceType, String
resourceName,
+ String clusterId, String detail) {
+ try {
+ operationAuditService.record(operation, resourceType,
resourceName, clusterId, detail, "SUCCESS", null);
+ } catch (Exception auditFailure) {
+ log.warn("Failed to record audit operation={} resource={}: {}",
operation, resourceName,
+ auditFailure.getMessage());
+ }
+ }
+
}
diff --git a/server/src/main/resources/application-dev.yml
b/server/src/main/resources/application-dev.yml
index f7efb59e..c6af6056 100644
--- a/server/src/main/resources/application-dev.yml
+++ b/server/src/main/resources/application-dev.yml
@@ -5,6 +5,10 @@ spring:
username: sa
password:
driver-class-name: org.h2.Driver
+ sql:
+ init:
+ mode: always
+ schema-locations: classpath:db/schema.sql
h2:
console:
enabled: true
diff --git a/server/src/main/resources/db/schema.sql
b/server/src/main/resources/db/schema.sql
index 6fc1f01d..a9a4c15a 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -24,6 +24,10 @@ CREATE TABLE IF NOT EXISTS rmq_instance (
remark VARCHAR(255),
type VARCHAR(32) NOT NULL COMMENT 'PROXY/DIRECT',
endpoint VARCHAR(512) NOT NULL,
+ vendor VARCHAR(32),
+ cloud_instance_id VARCHAR(128),
+ credential_id VARCHAR(64),
+ region_id VARCHAR(128),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -44,7 +48,7 @@ CREATE TABLE IF NOT EXISTS rmq_topic (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_cluster_topic (cluster_id, name),
- INDEX idx_instance (instance_id)
+ INDEX idx_topic_instance (instance_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 4. Consumer Group 管理记录
@@ -61,7 +65,7 @@ CREATE TABLE IF NOT EXISTS rmq_group (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_cluster_group (cluster_id, name),
- INDEX idx_instance (instance_id)
+ INDEX idx_group_instance (instance_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 5. K8s 证书管理
@@ -95,7 +99,7 @@ CREATE TABLE IF NOT EXISTS rmq_message_query (
cluster_id VARCHAR(255),
queried_by VARCHAR(64),
queried_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- INDEX idx_queried_at (queried_at),
+ INDEX idx_message_query_queried_at (queried_at),
INDEX idx_topic (topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -110,7 +114,7 @@ CREATE TABLE IF NOT EXISTS rmq_trace_query (
queried_by VARCHAR(64),
queried_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_msg_id (msg_id),
- INDEX idx_queried_at (queried_at)
+ INDEX idx_trace_query_queried_at (queried_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 8. 操作审计日志(所有写操作)
@@ -132,7 +136,7 @@ CREATE TABLE IF NOT EXISTS rmq_operation_audit (
-- 9. 通用设置(单行)
CREATE TABLE IF NOT EXISTS rmq_settings (
- id VARCHAR(16) PRIMARY KEY DEFAULT 'singleton',
+ id VARCHAR(16) DEFAULT 'singleton' PRIMARY KEY,
json TEXT NOT NULL COMMENT 'GeneralSettingsVO JSON',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/StudioApplicationTest.java
b/server/src/test/java/org/apache/rocketmq/studio/StudioApplicationTest.java
index 2881946c..125d65be 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/StudioApplicationTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/StudioApplicationTest.java
@@ -18,13 +18,20 @@ package org.apache.rocketmq.studio;
import org.apache.rocketmq.studio.ops.ai.tool.ToolCatalog;
import org.apache.rocketmq.studio.ops.ai.tool.ToolGatewayService;
+import org.apache.rocketmq.studio.persistence.mapper.RmqInstanceMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
+import
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
+@AutoConfigureMockMvc
class StudioApplicationTest {
@Autowired
@@ -33,9 +40,20 @@ class StudioApplicationTest {
@Autowired
private ToolGatewayService toolGatewayService;
+ @Autowired
+ private RmqInstanceMapper instanceMapper;
+
+ @Autowired
+ private MockMvc mockMvc;
+
@Test
- void applicationContextLoadsWithToolGateway() {
+ void applicationContextLoadsWithInitializedDevSchema() throws Exception {
assertThat(toolCatalog.getVersion()).isEqualTo("1.0.0");
assertThat(toolGatewayService.discover(null)).isNotEmpty();
+ assertThat(instanceMapper.selectList(null)).isEmpty();
+
+ mockMvc.perform(get("/api/instances"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data").isEmpty());
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RuntimeAdminClientResolverTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RuntimeAdminClientResolverTest.java
index efc118d3..913d03ce 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RuntimeAdminClientResolverTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RuntimeAdminClientResolverTest.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.cluster.broker;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.instance.InstanceVO;
import org.junit.jupiter.api.Test;
@@ -32,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -80,4 +82,23 @@ class RuntimeAdminClientResolverTest {
assertThat(result).isEqualTo("done");
verify(adminFactory).execute(eq("namesrv-b:9876"), isNull(), any());
}
+
+ @Test
+ void rejectsCloudInstancesBeforeResolvingOrExecutingAdminClient() {
+ InstanceVO instance = InstanceVO.builder()
+ .vendor(InstanceVendor.ALIYUN)
+ .endpoint("cloud-endpoint:9876")
+ .build();
+ instance.setId("cloud-instance");
+
when(instanceRepository.findById("cloud-instance")).thenReturn(Optional.of(instance));
+ RuntimeAdminClientResolver resolver = new
RuntimeAdminClientResolver(instanceRepository, adminFactory);
+
+ assertThatThrownBy(() -> resolver.resolveEndpoint("cloud-instance"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Runtime AdminClient only supports Apache
instances: cloud-instance");
+ assertThatThrownBy(() -> resolver.execute(instance, admin -> "unused"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Runtime AdminClient only supports Apache
instances: cloud-instance");
+ verifyNoInteractions(adminFactory);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ClientServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ClientServiceTest.java
index ce472901..e6152373 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ClientServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/client/ClientServiceTest.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.cluster.client;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -25,7 +26,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -52,12 +55,21 @@ class ClientServiceTest {
}
@Test
- void listConnectionsShouldTreatBlankFiltersAsUnspecified() {
- when(clientProvider.findConnections(null, null,
null)).thenReturn(List.of());
+ void listConnectionsShouldTreatBlankOptionalFiltersAsUnspecified() {
+ when(clientProvider.findConnections("instance-1", null,
null)).thenReturn(List.of());
- List<ClientConnectionVO> result = clientService.listConnections(" ", "
", "\t");
+ List<ClientConnectionVO> result =
clientService.listConnections("instance-1", " ", "\t");
assertThat(result).isEmpty();
- verify(clientProvider).findConnections(null, null, null);
+ verify(clientProvider).findConnections("instance-1", null, null);
+ }
+
+ @Test
+ void listConnectionsShouldRejectBlankInstanceIdBeforeQueryingProvider() {
+ assertThatThrownBy(() -> clientService.listConnections(" ", null,
null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("instanceId is required");
+
+ verifyNoInteractions(clientProvider);
}
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
index d93bc32e..72382060 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.cluster.k8s;
import org.apache.rocketmq.studio.common.domain.enums.CertStatus;
import org.apache.rocketmq.studio.common.domain.enums.CertType;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -38,6 +39,7 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -51,13 +53,16 @@ class K8sCertServiceTest {
@Mock
private K8sCertRepository k8sCertRepository;
+ @Mock
+ private OperationAuditService operationAuditService;
+
private K8sCertService k8sCertService;
private K8sCertVO sampleCert;
@BeforeEach
void setUp() {
- k8sCertService = new K8sCertService(k8sCertRepository, CLOCK);
+ k8sCertService = new K8sCertService(k8sCertRepository,
operationAuditService, CLOCK);
sampleCert = K8sCertVO.builder()
.name("rocketmq-tls")
.namespace("mq-system")
@@ -169,6 +174,9 @@ class K8sCertServiceTest {
assertThat(result.getCreatedAt()).isEqualTo(now);
assertThat(result.getUpdatedAt()).isEqualTo(now);
verify(k8sCertRepository).save(any(K8sCertVO.class));
+ verify(operationAuditService).record(eq("CREATE_K8S_CERTIFICATE"),
eq("K8S_CERTIFICATE"),
+ eq(result.getId()), eq(null), eq("name=new-tls-cert,
namespace=default, cluster=test-cluster"),
+ eq("SUCCESS"), eq(null));
}
@Test
@@ -246,6 +254,9 @@ class K8sCertServiceTest {
assertThat(sampleCert.getType()).isEqualTo(CertType.TLS);
assertThat(sampleCert.getUpdatedAt()).isEqualTo(LocalDateTime.of(2025,
1, 2, 0, 0));
verify(k8sCertRepository).save(any(K8sCertVO.class));
+ verify(operationAuditService).record(eq("UPDATE_K8S_CERTIFICATE"),
eq("K8S_CERTIFICATE"),
+ eq("cert-1"), eq(null), eq("name=updated-name,
namespace=new-namespace, cluster=new-cluster"),
+ eq("SUCCESS"), eq(null));
}
@Test
@@ -351,6 +362,9 @@ class K8sCertServiceTest {
assertThat(sampleCert.getNotBefore()).isEqualTo(originalNotBefore);
assertThat(sampleCert.getNotAfter()).isEqualTo(originalNotAfter);
verify(k8sCertRepository).save(any(K8sCertVO.class));
+ verify(operationAuditService).record(eq("RENEW_K8S_CERTIFICATE"),
eq("K8S_CERTIFICATE"),
+ eq("cert-1"), eq(null), eq("name=rocketmq-tls,
namespace=mq-system, cluster=prod-cluster"),
+ eq("SUCCESS"), eq(null));
}
@Test
@@ -396,6 +410,8 @@ class K8sCertServiceTest {
k8sCertService.deleteCert(command);
verify(k8sCertRepository).deleteById("cert-1");
+ verify(operationAuditService).record(eq("DELETE_K8S_CERTIFICATE"),
eq("K8S_CERTIFICATE"),
+ eq("cert-1"), eq(null), eq(null), eq("SUCCESS"), eq(null));
}
@Test
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index 01065024..ec2e0ec1 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -20,6 +20,7 @@ package org.apache.rocketmq.studio.instance;
import
org.apache.rocketmq.studio.provider.credential.CloudCredentialRepository;
import org.apache.rocketmq.studio.provider.credential.CloudCredentialVO;
import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.common.domain.enums.InstanceType;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -40,6 +41,8 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -63,6 +66,9 @@ class InstanceServiceTest {
@Mock
private MqAdminExtFactory adminFactory;
+ @Mock
+ private OperationAuditService operationAuditService;
+
@InjectMocks
private InstanceService instanceService;
@@ -259,6 +265,10 @@ class InstanceServiceTest {
assertThat(result.getUpdatedAt()).isNotNull();
assertThat(result.getName()).isEqualTo("new-instance");
verify(instanceRepository).save(any(InstanceVO.class));
+ verify(operationAuditService).record(eq("CREATE_INSTANCE"),
eq("INSTANCE"), eq(result.getId()), eq(null),
+ argThat(detail -> detail.equals("name=new-instance,
vendor=APACHE, type=PROXY")
+ && !detail.contains("10.0.1.1:8080")),
+ eq("SUCCESS"), eq(null));
}
@Test
@@ -327,6 +337,15 @@ class InstanceServiceTest {
assertThat(instanceService.createInstance(input).getEndpoint()).isEqualTo("namesrv:9876");
}
+ @Test
+ void createInstanceShouldTrimNameBeforeSaving() {
+ InstanceVO input = InstanceVO.builder().name(" production
").type(InstanceType.PROXY)
+ .endpoint("namesrv:9876").build();
+
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
+
+
assertThat(instanceService.createInstance(input).getName()).isEqualTo("production");
+ }
+
@Test
void updateInstanceShouldMergeFieldsOntoExisting() {
LocalDateTime originalCreatedAt = LocalDateTime.of(2025, 1, 2, 3, 4,
5);
@@ -367,6 +386,20 @@ class InstanceServiceTest {
assertThat(existing.getName()).isEqualTo("old-name");
assertThat(existing.getRemark()).isEqualTo("old remark");
assertThat(existing.getUpdatedAt()).isEqualTo(originalUpdatedAt);
+ verify(operationAuditService).record(eq("UPDATE_INSTANCE"),
eq("INSTANCE"), eq("inst-1"), eq(null),
+ eq("name=new-name, vendor=APACHE, type=PROXY"), eq("SUCCESS"),
eq(null));
+ }
+
+ @Test
+ void updateInstanceShouldTrimNameBeforeSaving() {
+ InstanceVO existing =
InstanceVO.builder().name("old-name").endpoint("namesrv:9876").build();
+ existing.setId("inst-1");
+ InstanceVO update = InstanceVO.builder().name(" production
").build();
+ update.setId("inst-1");
+
when(instanceRepository.findById("inst-1")).thenReturn(Optional.of(existing));
+
when(instanceRepository.save(any(InstanceVO.class))).thenAnswer(invocation ->
invocation.getArgument(0));
+
+
assertThat(instanceService.updateInstance(update).getName()).isEqualTo("production");
}
@Test
@@ -592,6 +625,8 @@ class InstanceServiceTest {
instanceService.deleteInstance("inst-1");
verify(instanceRepository).deleteById("inst-1");
+ verify(operationAuditService).record(eq("DELETE_INSTANCE"),
eq("INSTANCE"), eq("inst-1"), eq(null),
+ eq("name=to-delete, vendor=APACHE, type=null"), eq("SUCCESS"),
eq(null));
}
@Test
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
index 0a78fbcc..b3cd90c4 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
@@ -18,6 +18,7 @@
package org.apache.rocketmq.studio.instance.acl;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -37,6 +38,8 @@ import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,6 +50,9 @@ class AclServiceTest {
@Mock
private AclRepository aclRepository;
+ @Mock
+ private OperationAuditService operationAuditService;
+
@InjectMocks
private AclService aclService;
@@ -107,6 +113,8 @@ class AclServiceTest {
assertThat(result.getPrincipal()).isEqualTo("user1");
assertThat(result.getResource()).isEqualTo("topic-1");
verify(aclRepository).saveRule(any(AclRuleVO.class));
+ verify(operationAuditService).record(eq("CREATE_ACL_RULE"),
eq("ACL_RULE"), eq(result.getId()), eq(null),
+ eq("principal=user1"), eq("SUCCESS"), eq(null));
}
@Test
@@ -115,6 +123,8 @@ class AclServiceTest {
aclService.deleteRule("rule-1");
verify(aclRepository).deleteRule("rule-1");
+ verify(operationAuditService).record(eq("DELETE_ACL_RULE"),
eq("ACL_RULE"), eq("rule-1"), eq(null),
+ eq(null), eq("SUCCESS"), eq(null));
}
@Test
@@ -148,6 +158,8 @@ class AclServiceTest {
assertThat(result.getDecision()).isEqualTo("DENY");
verify(aclRepository).replaceRule(input);
verify(aclRepository, never()).saveRule(any(AclRuleVO.class));
+ verify(operationAuditService).record(eq("UPDATE_ACL_RULE"),
eq("ACL_RULE"), eq("rule-1"), eq(null),
+ eq("principal=user1"), eq("SUCCESS"), eq(null));
}
@Test
@@ -307,6 +319,10 @@ class AclServiceTest {
assertThat(result.getCreatedAt()).isNotNull();
assertThat(result.getUsername()).isEqualTo("newuser");
verify(aclRepository).saveUser(any(AclUserVO.class));
+ verify(operationAuditService).record(eq("CREATE_ACL_USER"),
eq("ACL_USER"), eq(result.getId()), eq(null),
+ argThat(detail -> detail.equals("username=newuser,
admin=false")
+ && !detail.contains(result.getAccessKey()) &&
!detail.contains(result.getSecretKey())),
+ eq("SUCCESS"), eq(null));
}
@Test
@@ -315,6 +331,8 @@ class AclServiceTest {
aclService.deleteUser("user-1");
verify(aclRepository).deleteUser("user-1");
+ verify(operationAuditService).record(eq("DELETE_ACL_USER"),
eq("ACL_USER"), eq("user-1"), eq(null),
+ eq(null), eq("SUCCESS"), eq(null));
}
@Test
@@ -365,6 +383,8 @@ class AclServiceTest {
verify(aclRepository).saveUser(captor.capture());
assertThat(captor.getValue().getAccessKey()).isEqualTo("access-key-123456");
assertThat(captor.getValue().getSecretKey()).isEqualTo("secret-key-987654");
+ verify(operationAuditService).record(eq("UPDATE_ACL_USER"),
eq("ACL_USER"), eq("user-1"), eq(null),
+ eq("username=newuser, admin=true"), eq("SUCCESS"), eq(null));
}
@Test
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
index 8d237eb6..8d96cc8b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/MessageServiceTest.java
@@ -14,13 +14,9 @@ import
org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
import org.junit.jupiter.api.Test;
-import java.util.Collections;
-
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
-import static org.mockito.Mockito.when;
class MessageServiceTest {
@@ -38,16 +34,29 @@ class MessageServiceTest {
}
@Test
- void keepsMessageIdQueryWithoutTopicValid() {
+ void rejectsMessageIdQueryWithoutTopicBeforeCallingProvider() {
MessageProvider provider = mock(MessageProvider.class);
InstanceProviderRegistry registry =
mock(InstanceProviderRegistry.class);
- when(provider.queryMessages(null, null, "msg-001", null, null, null,
null))
- .thenReturn(Collections.emptyList());
MessageService service = new MessageService(provider, registry);
- service.queryMessages(null, null, "msg-001", null, null, null, null);
+ assertThatThrownBy(() -> service.queryMessages(null, null, "msg-001",
null, null, null, null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("topic is required when msgId is specified");
+
+ verifyNoInteractions(provider, registry);
+ }
+
+ @Test
+ void rejectsBlankMessageTraceIdBeforeCallingProvider() {
+ MessageProvider provider = mock(MessageProvider.class);
+ InstanceProviderRegistry registry =
mock(InstanceProviderRegistry.class);
+ MessageService service = new MessageService(provider, registry);
+
+ assertThatThrownBy(() -> service.getMessageTrace("instance-a", " "))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("msgId is required");
- verify(provider).queryMessages(null, null, "msg-001", null, null,
null, null);
+ verifyNoInteractions(provider, registry);
}
@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 4d6d02e1..918a7fad 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
@@ -171,6 +171,27 @@ class MetadataServiceTest {
verify(apacheProvider).getTopicConsumers("instance-a", "orders");
}
+ @Test
+ void
runtimeDiagnosticsShouldRejectBlankTopicAndGroupNamesBeforeProviderResolution()
{
+ assertThatThrownBy(() -> metadataService.getTopicRoutes("instance-a",
" "))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("topic name is required");
+ assertThatThrownBy(() ->
metadataService.getTopicConsumers("instance-a", " "))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("topic name is required");
+ assertThatThrownBy(() ->
metadataService.getConsumerGroup("instance-a", " "))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("consumer group name is required");
+ assertThatThrownBy(() ->
metadataService.getGroupProgress("instance-a", " "))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("consumer group name is required");
+ assertThatThrownBy(() ->
metadataService.getGroupSubscriptions("instance-a", " "))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("consumer group name is required");
+
+ verifyNoInteractions(metadataProvider, adminClient, providerRegistry,
apacheProvider);
+ }
+
@Test
void sendMessageShouldReturnResult() {
SendMessageDTO request = SendMessageDTO.builder()
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
index 786ebf65..6d0acfda 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
@@ -16,7 +16,9 @@
*/
package org.apache.rocketmq.studio.ops.alert;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
import java.util.Collections;
import java.util.List;
@@ -36,7 +38,8 @@ class AlertServiceDefaultRulesTest {
AlertRepository repository = mock(AlertRepository.class);
when(repository.findAllRules()).thenReturn(Collections.emptyList());
- AlertService service = new AlertService(repository, new
AlertRuleAssetService());
+ AlertService service = new AlertService(repository, new
AlertRuleAssetService(),
+ Mockito.mock(OperationAuditService.class));
String yaml = service.exportPrometheusRulesYaml();
int ruleCount = countRules(yaml);
@@ -48,7 +51,8 @@ class AlertServiceDefaultRulesTest {
AlertRepository repository = mock(AlertRepository.class);
when(repository.findAllRules()).thenReturn(List.of());
- AlertService service = new AlertService(repository, new
AlertRuleAssetService());
+ AlertService service = new AlertService(repository, new
AlertRuleAssetService(),
+ Mockito.mock(OperationAuditService.class));
String yaml = service.exportPrometheusRulesYaml();
assertTrue(yaml.contains("rocketmq-broker.rules"));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
index 35a46555..73b440ce 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
@@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@@ -33,6 +34,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.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -43,11 +45,14 @@ class AlertServiceTest {
@Mock
private AlertRepository alertRepository;
+ @Mock
+ private OperationAuditService operationAuditService;
+
private AlertService alertService;
@org.junit.jupiter.api.BeforeEach
void setUp() {
- alertService = new AlertService(alertRepository, new
AlertRuleAssetService());
+ alertService = new AlertService(alertRepository, new
AlertRuleAssetService(), operationAuditService);
}
@Test
@@ -100,6 +105,7 @@ class AlertServiceTest {
.threshold(5000)
.duration("3m")
.description("Lag too high")
+ .enabled(true)
.build();
when(alertRepository.findAllRules()).thenReturn(List.of(rule));
@@ -122,6 +128,7 @@ class AlertServiceTest {
.threshold(1000)
.duration("3m")
.description("First lag alert")
+ .enabled(true)
.build();
AlertRuleVO second = AlertRuleVO.builder()
.name("Lag Alert B")
@@ -130,6 +137,7 @@ class AlertServiceTest {
.threshold(2000)
.duration("5m")
.description("Second lag alert")
+ .enabled(true)
.build();
when(alertRepository.findAllRules()).thenReturn(List.of(first,
second));
@@ -156,6 +164,7 @@ class AlertServiceTest {
.clusterName("DefaultCluster")
.severity("critical")
.description("Slave falls behind master")
+ .enabled(true)
.build();
when(alertRepository.findAllRules()).thenReturn(List.of(rule));
@@ -178,6 +187,7 @@ class AlertServiceTest {
.brokerName("*")
.clusterName(" ")
.severity("fatal")
+ .enabled(true)
.build();
when(alertRepository.findAllRules()).thenReturn(List.of(rule));
@@ -198,6 +208,7 @@ class AlertServiceTest {
.clusterName("prod\"east\\dc\nline")
.brokerName("broker\tone")
.description("Summary \"quoted\" \\ path\nnext -
Details\twith\rline")
+ .enabled(true)
.build();
when(alertRepository.findAllRules()).thenReturn(List.of(rule));
@@ -221,6 +232,7 @@ class AlertServiceTest {
.metric("rocketmq_consumer_lag_messages")
.operator(">")
.threshold(1)
+ .enabled(true)
.build();
when(alertRepository.findAllRules()).thenReturn(List.of(rule));
@@ -231,6 +243,50 @@ class AlertServiceTest {
assertThat(exportedRule.path("alert").asText()).isEqualTo("RocketMQAlert");
}
+ @Test
+ void exportPrometheusRulesYamlShouldExcludeDisabledRules() {
+ AlertRuleVO enabled = AlertRuleVO.builder()
+ .name("Enabled Lag Alert")
+ .metric("rocketmq_consumer_lag_messages")
+ .operator(">")
+ .threshold(1000)
+ .enabled(true)
+ .build();
+ AlertRuleVO disabled = AlertRuleVO.builder()
+ .name("Disabled Lag Alert")
+ .metric("rocketmq_consumer_lag_messages")
+ .operator(">")
+ .threshold(2000)
+ .enabled(false)
+ .build();
+ when(alertRepository.findAllRules()).thenReturn(List.of(enabled,
disabled));
+
+ String result = alertService.exportPrometheusRulesYaml();
+
+ assertThat(result)
+ .contains("EnabledLagAlert")
+ .doesNotContain("DisabledLagAlert")
+ .doesNotContain(" > 2000");
+ }
+
+ @Test
+ void
exportPrometheusRulesYamlShouldUseDefaultRulesWhenAllConfiguredRulesAreDisabled()
{
+ AlertRuleVO disabled = AlertRuleVO.builder()
+ .name("Disabled Lag Alert")
+ .metric("rocketmq_consumer_lag_messages")
+ .operator(">")
+ .threshold(2000)
+ .enabled(false)
+ .build();
+ when(alertRepository.findAllRules()).thenReturn(List.of(disabled));
+
+ String result = alertService.exportPrometheusRulesYaml();
+
+ assertThat(result)
+ .contains("RocketMQBrokerDown")
+ .doesNotContain("DisabledLagAlert");
+ }
+
@Test
void createRuleShouldPreserveReplicationScopeFields() {
AlertRuleVO input = AlertRuleVO.builder()
@@ -252,6 +308,8 @@ class AlertServiceTest {
assertThat(result.getClusterName()).isEqualTo("DefaultCluster");
assertThat(result.getSeverity()).isEqualTo("critical");
verify(alertRepository).saveRule(result);
+ verify(operationAuditService).record(eq("CREATE_ALERT_RULE"),
eq("ALERT_RULE"), eq(result.getId()),
+ eq(null), eq("name=Replication Lag High"), eq("SUCCESS"),
eq(null));
}
@Test
@@ -300,6 +358,8 @@ class AlertServiceTest {
assertThat(result.getId()).isEqualTo("rule-1");
assertThat(result.getThreshold()).isEqualTo(90.0);
verify(alertRepository).replaceRule(update);
+ verify(operationAuditService).record(eq("UPDATE_ALERT_RULE"),
eq("ALERT_RULE"), eq("rule-1"),
+ eq(null), eq("name=CPU Alert"), eq("SUCCESS"), eq(null));
}
@Test
@@ -357,6 +417,8 @@ class AlertServiceTest {
assertThat(result.isEnabled()).isTrue();
verify(alertRepository).saveRule(result);
+ verify(operationAuditService).record(eq("TOGGLE_ALERT_RULE"),
eq("ALERT_RULE"), eq("rule-1"),
+ eq(null), eq("enabled=true"), eq("SUCCESS"), eq(null));
}
@Test
@@ -386,6 +448,8 @@ class AlertServiceTest {
alertService.deleteRule("rule-1");
verify(alertRepository).deleteRule("rule-1");
+ verify(operationAuditService).record(eq("DELETE_ALERT_RULE"),
eq("ALERT_RULE"), eq("rule-1"),
+ eq(null), eq(null), eq("SUCCESS"), eq(null));
}
@Test
@@ -437,6 +501,8 @@ class AlertServiceTest {
assertThat(result.isAcknowledged()).isTrue();
verify(alertRepository).saveAlert(result);
+ verify(operationAuditService).record(eq("ACKNOWLEDGE_SYSTEM_ALERT"),
eq("SYSTEM_ALERT"), eq("a1"),
+ eq(null), eq("acknowledged=true"), eq("SUCCESS"), eq(null));
}
@Test
@@ -456,6 +522,8 @@ class AlertServiceTest {
assertThat(result).isEqualTo(3);
verify(alertRepository).deleteAcknowledgedAlerts();
+
verify(operationAuditService).record(eq("CLEAR_ACKNOWLEDGED_SYSTEM_ALERTS"),
eq("SYSTEM_ALERT"),
+ eq(null), eq(null), eq("deleted=3"), eq("SUCCESS"), eq(null));
}
@Test
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
index 2bd23a1d..804ec59b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
@@ -19,6 +19,7 @@ import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.TopicConfig;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.client.producer.SendResult;
+import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.exception.RemotingTimeoutException;
import org.apache.rocketmq.remoting.protocol.ResponseCode;
@@ -338,6 +339,7 @@ class RocketMQAdminClientImplTest {
mockConstruction(DefaultMQProducer.class, (producer,
context) -> {
doNothing().when(producer).start();
SendResult sendResult = new SendResult();
+ sendResult.setSendStatus(SendStatus.SEND_OK);
sendResult.setMsgId("msg-1");
sendResult.setOffsetMsgId("offset-1");
when(producer.send(any(Message.class))).thenReturn(sendResult);
@@ -359,6 +361,7 @@ class RocketMQAdminClientImplTest {
mockConstruction(DefaultMQProducer.class, (producer,
context) -> {
doNothing().when(producer).start();
SendResult sendResult = new SendResult();
+ sendResult.setSendStatus(SendStatus.SEND_OK);
sendResult.setMsgId("msg-1");
sendResult.setOffsetMsgId("offset-1");
when(producer.send(any(Message.class))).thenReturn(sendResult);
@@ -375,4 +378,50 @@ class RocketMQAdminClientImplTest {
verify(producer).setNamesrvAddr("10.0.0.2:9876");
}
}
+
+ @Test
+ void sendMessageShouldRejectNonSuccessfulSendStatus() throws Exception {
+ when(properties.getNamesrvAddr()).thenReturn("10.0.0.1:9876");
+ try (MockedConstruction<DefaultMQProducer> mockedProducers =
+ mockConstruction(DefaultMQProducer.class, (producer,
context) -> {
+ doNothing().when(producer).start();
+ SendResult sendResult = new SendResult();
+
sendResult.setSendStatus(SendStatus.FLUSH_DISK_TIMEOUT);
+
when(producer.send(any(Message.class))).thenReturn(sendResult);
+ doNothing().when(producer).shutdown();
+ })) {
+ SendMessageDTO request = new SendMessageDTO();
+ request.setTopic("TopicA");
+ request.setBody("hello");
+
+ assertThatThrownBy(() -> adminClient.sendMessage(request))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("FLUSH_DISK_TIMEOUT");
+
+ verify(auditService).record("SEND_MESSAGE", "TopicA",
+ "Message send did not succeed: FLUSH_DISK_TIMEOUT",
"FAILED");
+ }
+ }
+
+ @Test
+ void sendMessageShouldRejectNullSendResult() throws Exception {
+ when(properties.getNamesrvAddr()).thenReturn("10.0.0.1:9876");
+ try (MockedConstruction<DefaultMQProducer> mockedProducers =
+ mockConstruction(DefaultMQProducer.class, (producer,
context) -> {
+ doNothing().when(producer).start();
+
when(producer.send(any(Message.class))).thenReturn(null);
+ doNothing().when(producer).shutdown();
+ })) {
+ SendMessageDTO request = new SendMessageDTO();
+ request.setTopic("TopicA");
+ request.setBody("hello");
+
+ assertThatThrownBy(() -> adminClient.sendMessage(request))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("null");
+
+ verify(auditService).record("SEND_MESSAGE", "TopicA",
+ "Message send did not succeed: null", "FAILED");
+ }
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
index d5c8aa51..6028ceb0 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
@@ -256,4 +256,17 @@ class RocketMQMessageProviderTest {
assertThat(transaction.getDescription()).contains("tx-group").contains("COMMIT_MESSAGE");
assertThat(record.getConsumerStatus()).isEmpty();
}
+
+ @Test
+ void getMessageTraceSurfacesAdminFailure() throws Exception {
+ when(adminExt.queryMessage(anyString(), anyString(), anyInt(),
anyLong(), anyLong()))
+ .thenThrow(new IllegalStateException("broker unavailable"));
+
+ assertThatThrownBy(() -> provider.getMessageTrace("instance-a",
"msg-123"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Failed to query message trace: broker
unavailable")
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(502));
+
+ verify(queryHistoryService, never()).recordTraceQuery(anyString(),
anyString(), any(), anyInt(), anyInt());
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
index 9c796c78..d62c1869 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
@@ -20,6 +20,7 @@ import
org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.util.CredentialUtils;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.instance.InstanceRepository;
+import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.provider.alibaba.AliyunClientFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -33,6 +34,8 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -49,6 +52,9 @@ class CloudCredentialServiceTest {
@Mock
private AliyunClientFactory aliyunClientFactory;
+ @Mock
+ private OperationAuditService operationAuditService;
+
@InjectMocks
private CloudCredentialService service;
@@ -124,6 +130,10 @@ class CloudCredentialServiceTest {
assertThat(created.getId()).isNotBlank();
assertThat(created.getAccessKey()).isEqualTo("LTAI****0001");
assertThat(created.getSecretKey()).isNull();
+ verify(operationAuditService).record(eq("CREATE_CLOUD_CREDENTIAL"),
eq("CLOUD_CREDENTIAL"),
+ eq(created.getId()), eq(null), argThat(detail ->
detail.equals("name=ok, vendor=ALIYUN")
+ && !detail.contains("LTAI5tGoodKey00000000001") &&
!detail.contains("sk-value")),
+ eq("SUCCESS"), eq(null));
}
@Test
@@ -157,6 +167,8 @@ class CloudCredentialServiceTest {
service.update(request);
verify(aliyunClientFactory).invalidateCredential("cred-1");
+ verify(operationAuditService).record(eq("UPDATE_CLOUD_CREDENTIAL"),
eq("CLOUD_CREDENTIAL"),
+ eq("cred-1"), eq(null), eq("name=null, vendor=ALIYUN"),
eq("SUCCESS"), eq(null));
}
@Test
@@ -171,6 +183,8 @@ class CloudCredentialServiceTest {
verify(credentialRepository).deleteById("cred-1");
verify(aliyunClientFactory).invalidateCredential("cred-1");
+ verify(operationAuditService).record(eq("DELETE_CLOUD_CREDENTIAL"),
eq("CLOUD_CREDENTIAL"),
+ eq("cred-1"), eq(null), eq("name=null, vendor=ALIYUN"),
eq("SUCCESS"), eq(null));
}
@Test
diff --git a/web/src/api/producer.test.ts b/web/src/api/producer.test.ts
index e01257e2..6303b075 100644
--- a/web/src/api/producer.test.ts
+++ b/web/src/api/producer.test.ts
@@ -34,12 +34,15 @@ describe('Producer API', () => {
});
it('fetches Studio topic records sorted alphabetically', async () => {
- mock.onGet('/topics').reply(200, {
+ mock.onGet('/topics').reply((config) => {
+ expect(config.params.instanceId).toBe('instance-1');
+ return [200, {
code: 200,
data: [{ name: 'order-events' }, { name: 'user-signup' }, { name:
'batch-process' }],
+ }];
});
- const result = await fetchTopicList();
+ const result = await fetchTopicList('instance-1');
expect(result).toEqual(['batch-process', 'order-events', 'user-signup']);
});
@@ -48,14 +51,14 @@ describe('Producer API', () => {
topicList: ['order-events', 'user-signup', 'batch-process'],
});
- const result = await fetchTopicList();
+ const result = await fetchTopicList('instance-1');
expect(result).toEqual(['batch-process', 'order-events', 'user-signup']);
});
it('handles empty topic list', async () => {
mock.onGet('/topics').reply(200, { topicList: [] });
- const result = await fetchTopicList();
+ const result = await fetchTopicList('instance-1');
expect(result).toEqual([]);
});
diff --git a/web/src/api/producer.ts b/web/src/api/producer.ts
index e01c0a0c..61138103 100644
--- a/web/src/api/producer.ts
+++ b/web/src/api/producer.ts
@@ -36,9 +36,9 @@ interface TopicListResponse {
// ─── API ────────────────────────────────────────────────────────
-/** Fetch all topic names */
-export async function fetchTopicList(): Promise<string[]> {
- const res = await client.get<TopicListResponse>('/topics');
+/** Fetch topic names for a managed instance. */
+export async function fetchTopicList(instanceId: string): Promise<string[]> {
+ const res = await client.get<TopicListResponse>('/topics', { params: {
instanceId } });
const topics = res.data.data?.map((topic) => topic.name) ??
res.data.topicList ?? [];
return topics.sort();
}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 46dcdb3a..66050329 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -305,6 +305,14 @@ const translations: Record<string, Record<Lang, string>> =
{
zh: '访问控制规则与用户权限管理,共 {rules} 条规则、{users} 个用户',
en: 'Access control rules and user permissions, {rules} rules, {users}
users',
},
+ 'acl.localMetadataNotice': {
+ zh: '当前 ACL 规则和用户仅保存为 Studio 本地元数据',
+ en: 'Current ACL rules and users are stored as Studio-local metadata',
+ },
+ 'acl.localMetadataDescription': {
+ zh: '它们尚不会下发到所选 RocketMQ 实例。实例级 ACL Provider 接入完成前,请在集群侧管理实际 ACL 策略。',
+ en: 'They are not applied to the selected RocketMQ instance. Manage the
effective ACL policy on the cluster until instance-scoped provider support is
available.',
+ },
'acl.addRule': { zh: '添加规则', en: 'Add Rule' },
'acl.addUser': { zh: '添加用户', en: 'Add User' },
'acl.ruleTab': { zh: 'ACL 规则', en: 'ACL Rules' },
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index 3ffae0b4..88c486de 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -23,6 +23,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect,
it, vi } from 'vite
import type { ClientConnection } from '../../../api/connections';
import { LangProvider } from '../../../i18n/LangContext';
import * as connectionsService from '../../../services/connectionsService';
+import * as instanceService from '../../../services/instanceService';
import ClientsPage from '../clients';
vi.mock('../../../services/connectionsService', () => ({
@@ -221,6 +222,33 @@ describe('Clients page', () => {
expect(within(screen.getByTestId('connection-total')).getByText('0')).toBeInTheDocument();
});
+ it('surfaces instance discovery failures and allows retrying', async () => {
+ vi.mocked(instanceService.listInstances)
+ .mockRejectedValueOnce(new Error('Unable to load managed instances'))
+ .mockResolvedValueOnce([
+ {
+ id: 'instance-1',
+ name: 'Instance 1',
+ endpoint: 'namesrv-1:9876',
+ type: 'DIRECT',
+ remark: '',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '',
+ updatedAt: '',
+ },
+ ]);
+ const user = userEvent.setup();
+ renderWithProviders(<ClientsPage />);
+
+ expect(await screen.findByText('Unable to load managed
instances')).toBeInTheDocument();
+ expect(connectionsService.listConnections).not.toHaveBeenCalled();
+
+ await user.click(screen.getByRole('button', { name: /重\s*试/ }));
+ await screen.findByText('[email protected]:49152');
+ expect(connectionsService.listConnections).toHaveBeenCalledWith({
instanceId: 'instance-1' });
+ });
+
it('opens a client detail dialog from the connection table', async () => {
const user = userEvent.setup();
renderWithProviders(<ClientsPage />);
diff --git a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
index 6b4903b5..aa9aa43f 100644
--- a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
@@ -101,6 +101,14 @@ describe('K8sCertsPage', () => {
expect(screen.getByText('-')).toBeInTheDocument();
});
+ it('explains that certificate records are Studio-local metadata', async ()
=> {
+ renderPage();
+
+ expect(await
screen.findByTestId('k8s-cert-local-metadata-notice')).toHaveTextContent(
+ '当前证书记录仅保存为 Studio 本地元数据',
+ );
+ });
+
it.each([
['platform', 'rocketmq-staging-tls', 'rocketmq-prod-tls'],
['broker.prod.example.com', 'rocketmq-prod-tls', 'rocketmq-staging-tls'],
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index 400db782..9a971891 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -28,6 +28,7 @@ import {
Space,
Typography,
Card,
+ Alert,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -356,6 +357,14 @@ const K8sCertsPage = () => {
</Button>
}
/>
+ <Alert
+ data-testid="k8s-cert-local-metadata-notice"
+ type="warning"
+ showIcon
+ message="当前证书记录仅保存为 Studio 本地元数据"
+ description="创建、续期和删除操作尚不会应用到 Kubernetes 集群或
cert-manager。请在集群侧管理实际证书,直到 Kubernetes Provider 接入完成。"
+ style={{ marginBottom: 16 }}
+ />
<Flex justify="space-between" style={{ marginBottom: 16 }}>
<Space>
<Input.Search
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index f4febdfb..92efe529 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -114,18 +114,34 @@ const ClientsPage = () => {
const [clusterFilter, setClusterFilter] = useState<string>('ALL');
const [selectedConnection, setSelectedConnection] =
useState<ClientConnection | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
+ const [instanceLoadKey, setInstanceLoadKey] = useState(0);
useEffect(() => {
let cancelled = false;
- void listInstances().then((nextInstances) => {
- if (cancelled) return;
- setInstances(nextInstances);
- setSelectedInstanceId((current) => current || nextInstances[0]?.id ||
'');
- });
+
+ setLoading(true);
+ void listInstances()
+ .then((nextInstances) => {
+ if (cancelled) return;
+ setInstances(nextInstances);
+ setSelectedInstanceId((current) => current || nextInstances[0]?.id ||
'');
+ setLoadError(null);
+ })
+ .catch((error) => {
+ if (cancelled) return;
+ setInstances([]);
+ setSelectedInstanceId('');
+ setConnections([]);
+ setLoadError(getLoadErrorMessage(error));
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+
return () => {
cancelled = true;
};
- }, []);
+ }, [instanceLoadKey]);
useEffect(() => {
let cancelled = false;
@@ -355,7 +371,17 @@ const ClientsPage = () => {
/>
{loadError && (
- <Alert showIcon type="warning" message={loadError} style={{
marginBottom: 16 }} />
+ <Alert
+ showIcon
+ type="warning"
+ message={loadError}
+ style={{ marginBottom: 16 }}
+ action={
+ <Button size="small" onClick={() => setInstanceLoadKey((key) =>
key + 1)}>
+ 重试
+ </Button>
+ }
+ />
)}
{connections.some((connection) => connection.partial) && (
<Alert
diff --git a/web/src/pages/home/__tests__/DashboardPage.test.tsx
b/web/src/pages/home/__tests__/DashboardPage.test.tsx
new file mode 100644
index 00000000..4d28874c
--- /dev/null
+++ b/web/src/pages/home/__tests__/DashboardPage.test.tsx
@@ -0,0 +1,118 @@
+/*
+ * 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.
+ */
+
+import { App } from 'antd';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { DashboardData } from '../../../api/metrics';
+import { LangProvider } from '../../../i18n/LangContext';
+import * as dashboardService from '../../../services/dashboardService';
+import * as instanceService from '../../../services/instanceService';
+import DashboardPage from '../dashboard';
+
+vi.mock('../../../services/dashboardService', () => ({ getDashboard: vi.fn()
}));
+vi.mock('../../../services/instanceService', () => ({ listInstances: vi.fn()
}));
+
+const dashboard = (name: string): DashboardData => ({
+ stats: {
+ totalClusters: 1,
+ healthyClusters: 1,
+ totalBrokers: 1,
+ totalProxies: 0,
+ totalNameServers: 1,
+ totalTopics: 1,
+ totalConsumerGroups: 1,
+ totalMessagesToday: 1,
+ messagesPerSecond: 1,
+ tpsIn: 1,
+ tpsOut: 1,
+ },
+ clusters: [
+ {
+ id: name,
+ name,
+ type: 'V4_NAMESRV',
+ status: 'healthy',
+ brokers: 1,
+ proxies: 0,
+ topics: 1,
+ groups: 1,
+ tpsIn: 1,
+ tpsOut: 1,
+ version: '5.0.0',
+ throughput: [1],
+ },
+ ],
+});
+
+const deferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise<T>((promiseResolve) => {
+ resolve = promiseResolve;
+ });
+ return { promise, resolve };
+};
+
+const renderWithProviders = (ui: React.ReactElement) =>
+ render(
+ <App>
+ <LangProvider>
+ <MemoryRouter>{ui}</MemoryRouter>
+ </LangProvider>
+ </App>,
+ );
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation(() => ({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+ });
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(instanceService.listInstances).mockResolvedValue([
+ { id: 'instance-a', name: 'Instance A', endpoint: 'a:9876', type:
'DIRECT', remark: '', topicCount: 0, consumerGroupCount: 0, createdAt: '',
updatedAt: '' },
+ { id: 'instance-b', name: 'Instance B', endpoint: 'b:9876', type:
'DIRECT', remark: '', topicCount: 0, consumerGroupCount: 0, createdAt: '',
updatedAt: '' },
+ ]);
+});
+
+describe('DashboardPage', () => {
+ it('does not let a stale instance response overwrite the latest selection',
async () => {
+ const instanceA = deferred<DashboardData>();
+ const instanceB = deferred<DashboardData>();
+ vi.mocked(dashboardService.getDashboard)
+ .mockResolvedValueOnce(dashboard('initial-cluster'))
+ .mockReturnValueOnce(instanceA.promise)
+ .mockReturnValueOnce(instanceB.promise);
+ const user = userEvent.setup();
+ renderWithProviders(<DashboardPage />);
+
+ await screen.findByText('initial-cluster');
+ const selector = screen.getByRole('combobox', { name: 'Dashboard instance'
});
+ await user.click(selector);
+ await user.click(await screen.findByText('Instance A', { selector:
'.ant-select-item-option-content' }));
+ await user.click(selector);
+ await user.click(await screen.findByText('Instance B', { selector:
'.ant-select-item-option-content' }));
+
+ instanceB.resolve(dashboard('instance-b-cluster'));
+ expect(await screen.findByText('instance-b-cluster')).toBeInTheDocument();
+
+ instanceA.resolve(dashboard('instance-a-cluster'));
+ await waitFor(() => {
+ expect(screen.queryByText('instance-a-cluster')).not.toBeInTheDocument();
+ });
+ expect(screen.getByText('instance-b-cluster')).toBeInTheDocument();
+ });
+});
diff --git a/web/src/pages/home/dashboard.tsx b/web/src/pages/home/dashboard.tsx
index 8eced776..f6ec900c 100644
--- a/web/src/pages/home/dashboard.tsx
+++ b/web/src/pages/home/dashboard.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
@@ -37,16 +37,25 @@ const DashboardPage = () => {
const [selectedInstanceId, setSelectedInstanceId] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
+ const dashboardRequestIdRef = useRef(0);
const loadDashboard = useCallback(async () => {
+ const requestId = ++dashboardRequestIdRef.current;
setLoading(true);
setLoadError(false);
try {
- setDashboard(await getDashboard(selectedInstanceId));
+ const nextDashboard = await getDashboard(selectedInstanceId);
+ if (requestId === dashboardRequestIdRef.current) {
+ setDashboard(nextDashboard);
+ }
} catch {
- setLoadError(true);
+ if (requestId === dashboardRequestIdRef.current) {
+ setLoadError(true);
+ }
} finally {
- setLoading(false);
+ if (requestId === dashboardRequestIdRef.current) {
+ setLoading(false);
+ }
}
}, [selectedInstanceId]);
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index a67a9725..379535de 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -121,6 +121,14 @@ describe('ACL page', () => {
expect(screen.getByText('cluster-a')).toBeInTheDocument();
});
+ it('explains that ACL records are Studio-local metadata', async () => {
+ renderWithProviders(<AclPage />);
+
+ expect(await
screen.findByTestId('acl-local-metadata-notice')).toHaveTextContent(
+ '当前 ACL 规则和用户仅保存为 Studio 本地元数据',
+ );
+ });
+
it('renders backend users on the user tab', async () => {
const user = userEvent.setup();
renderWithProviders(<AclPage />);
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index fbd4f7e6..b55778af 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -43,6 +43,17 @@ vi.mock('../../../services/instanceService', () => ({
createdAt: '',
updatedAt: '',
},
+ {
+ id: 'instance-2',
+ name: 'Instance 2',
+ endpoint: 'namesrv-2:9876',
+ type: 'DIRECT',
+ remark: '',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '',
+ updatedAt: '',
+ },
]),
}));
@@ -253,4 +264,39 @@ describe('DLQ page', () => {
expect(await screen.findByText('DLQ provider is not
configured')).toBeInTheDocument();
});
+
+ it('clears retry state before loading groups for a newly selected instance',
async () => {
+ let resolveSecondInstance!: (groups: DLQGroup[]) => void;
+ vi.mocked(messageService.listDLQGroups)
+ .mockResolvedValueOnce([dlqGroup])
+ .mockImplementationOnce(
+ () =>
+ new Promise<DLQGroup[]>((resolve) => {
+ resolveSecondInstance = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ const orderRow = (await screen.findByText('cg-order')).closest('tr');
+ if (!orderRow) throw new Error('DLQ group row not found');
+
+ await user.click(within(orderRow).getByRole('button', { name: '重投消息' }));
+ expect(await screen.findByText('重投死信消息')).toBeInTheDocument();
+
+ await user.click(screen.getAllByRole('combobox')[0]);
+ await user.click(
+ await screen.findByText('Instance 2', { selector:
'.ant-select-item-option-content' }),
+ );
+
+ await waitFor(() => {
+
expect(messageService.listDLQGroups).toHaveBeenLastCalledWith('instance-2');
+ });
+ await waitFor(() => {
+ expect(screen.queryByRole('row', { name: /cg-order/
})).not.toBeInTheDocument();
+ });
+
+ resolveSecondInstance([secondDlqGroup]);
+ expect(await screen.findByText('-cg-"payment"')).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index 1ae39b55..36dd97a3 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -79,6 +79,8 @@ const renderWithProviders = (ui: React.ReactElement) =>
</App>,
);
+const lastElement = <T,>(elements: T[]): T => elements[elements.length - 1]!;
+
describe('Message page query history', () => {
beforeEach(() => {
localStorage.clear();
@@ -96,11 +98,14 @@ describe('Message page query history', () => {
expect(screen.getByRole('button', { name: /最近查询/ })).toBeDisabled();
await user.click(screen.getByText('按 Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
await user.type(screen.getByPlaceholderText('输入 Message ID'), 'MID-001');
await user.click(screen.getByRole('button', { name: /^search查询$/ }));
await waitFor(() => {
expect(messageServiceMocks.queryMessages).toHaveBeenCalledWith({
+ topic: 'order-create',
msgId: 'MID-001',
instanceId: '',
});
@@ -112,11 +117,12 @@ describe('Message page query history', () => {
renderWithProviders(<MessagePage />);
await user.click(screen.getByRole('button', { name: /最近查询/ }));
- await user.click(await screen.findByText('Message ID: MID-001'));
+ await user.click(await screen.findByText('Message ID: MID-001 · Topic:
order-create'));
expect(screen.getByPlaceholderText('输入 Message
ID')).toHaveValue('MID-001');
await waitFor(() => {
expect(messageServiceMocks.queryMessages).toHaveBeenCalledWith({
+ topic: 'order-create',
msgId: 'MID-001',
instanceId: '',
});
@@ -134,11 +140,14 @@ describe('Message page query history', () => {
renderWithProviders(<MessagePage />);
await user.click(screen.getByText('按 Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
await user.type(screen.getByPlaceholderText('输入 Message ID'),
'MID-FAILED');
await user.click(screen.getByRole('button', { name: /^search查询$/ }));
await waitFor(() => {
expect(messageServiceMocks.queryMessages).toHaveBeenCalledWith({
+ topic: 'order-create',
msgId: 'MID-FAILED',
instanceId: '',
});
@@ -151,6 +160,8 @@ describe('Message page query history', () => {
const user = userEvent.setup();
renderWithProviders(<MessagePage />);
await user.click(screen.getByText('按 Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
const messageIdInput = screen.getByPlaceholderText('输入 Message ID');
const queryButton = screen.getByRole('button', { name: /^search查询$/ });
@@ -240,15 +251,16 @@ describe('Message page query history', () => {
JSON.stringify([
{ mode: 'topic', params: { topic: ['invalid'] } },
{ mode: 'unknown', params: { topic: 'order-create' } },
- { mode: 'msgid', params: { msgId: 'MID-VALID' } },
+ { mode: 'msgid', params: { msgId: 'MID-VALID', topic: 'order-create' }
},
]),
);
renderWithProviders(<MessagePage />);
await user.click(screen.getByRole('button', { name: /最近查询/ }));
- expect(await screen.findByText('Message ID:
MID-VALID')).toBeInTheDocument();
+ expect(
+ await screen.findByText('Message ID: MID-VALID · Topic: order-create'),
+ ).toBeInTheDocument();
expect(screen.queryByText(/invalid/)).not.toBeInTheDocument();
- expect(screen.queryByText(/order-create/)).not.toBeInTheDocument();
});
it('does not report consume verification success without a backend API',
async () => {
@@ -257,6 +269,8 @@ describe('Message page query history', () => {
renderWithProviders(<MessagePage />);
await user.click(screen.getByText('按 Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
await user.type(screen.getByPlaceholderText('输入 Message ID'),
'MID-CONSUME-VERIFY-001');
await user.click(screen.getByRole('button', { name: /^search查询$/ }));
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index e6e3e469..374320c0 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -33,6 +33,7 @@ import {
Badge,
Typography,
Flex,
+ Alert,
message,
} from 'antd';
import { Plus, MagnifyingGlass, ShieldCheck, User, Eye, EyeSlash } from
'@phosphor-icons/react';
@@ -655,6 +656,15 @@ const AclPage = () => {
}
/>
+ <Alert
+ data-testid="acl-local-metadata-notice"
+ type="warning"
+ showIcon
+ message={t('acl.localMetadataNotice')}
+ description={t('acl.localMetadataDescription')}
+ style={{ marginBottom: 16 }}
+ />
+
<Card bordered={false} bodyStyle={{ padding: 0 }}>
<Tabs
activeKey={activeTab}
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 7f3a5873..dfd574fc 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -125,12 +125,22 @@ const DLQPage = () => {
useEffect(() => {
let cancelled = false;
+ // The retry dialog owns a group name that is meaningful only for the
+ // currently selected instance. Clear all instance-scoped state before
+ // starting the next request so an old group cannot be retried on a new
+ // instance while that request is in flight.
+ setGroups([]);
+ setSelectedGroupNames([]);
+ setDetailGroup(null);
+ setRetryModalOpen(false);
+ setRetryGroup(null);
+ setRetryTargetTopic('');
+ setRetryError(null);
+ setLoadError(null);
+
if (!selectedInstanceId) {
void Promise.resolve().then(() => {
if (cancelled) return;
- setGroups([]);
- setSelectedGroupNames([]);
- setLoadError(null);
setLoading(false);
});
return () => {
@@ -138,6 +148,7 @@ const DLQPage = () => {
};
}
+ setLoading(true);
void listDLQGroups(selectedInstanceId)
.then((nextGroups) => {
if (!cancelled) {
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 96bf564c..c4a7d43a 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -161,21 +161,23 @@ const isMessageQuery = (value: unknown): value is
MessageQuery => {
);
};
+const isRecentQuery = (value: unknown): value is RecentQuery => {
+ if (typeof value !== 'object' || value === null) return false;
+ const query = value as RecentQuery;
+ return (
+ isQueryMode(query.mode) &&
+ isMessageQuery(query.params) &&
+ (query.mode !== 'msgid' || Boolean(query.params.topic?.trim()))
+ );
+};
+
const loadRecentQueries = (): RecentQuery[] => {
try {
const stored = localStorage.getItem(QUERY_HISTORY_STORAGE_KEY);
if (!stored) return [];
const parsed: unknown = JSON.parse(stored);
if (!Array.isArray(parsed)) return [];
- return parsed
- .filter(
- (item): item is RecentQuery =>
- typeof item === 'object' &&
- item !== null &&
- isQueryMode((item as RecentQuery).mode) &&
- isMessageQuery((item as RecentQuery).params),
- )
- .slice(0, MAX_QUERY_HISTORY);
+ return parsed.filter(isRecentQuery).slice(0, MAX_QUERY_HISTORY);
} catch {
return [];
}
@@ -184,7 +186,8 @@ const loadRecentQueries = (): RecentQuery[] => {
const querySignature = (query: RecentQuery): string => JSON.stringify(query);
const queryLabel = ({ mode, params }: RecentQuery): string => {
- if (mode === 'msgid') return `Message ID: ${params.msgId || '全部'}`;
+ if (mode === 'msgid')
+ return `Message ID: ${params.msgId || '全部'} · Topic: ${params.topic ||
'全部'}`;
if (mode === 'key') {
return `Key: ${params.key || '全部'}${params.topic ? ` · Topic:
${params.topic}` : ''}`;
}
@@ -318,7 +321,7 @@ const MessagePage = () => {
}
: queryMode === 'key'
? { topic: selectedTopic, key: keyInput || undefined }
- : { msgId: msgIdInput || undefined };
+ : { topic: selectedTopic, msgId: msgIdInput || undefined };
await executeQuery(queryMode, params);
};
@@ -749,12 +752,26 @@ const MessagePage = () => {
)}
{queryMode === 'msgid' && (
- <Input
- placeholder="输入 Message ID"
- style={{ width: 400 }}
- value={msgIdInput}
- onChange={(e) => setMsgIdInput(e.target.value)}
- />
+ <>
+ <Select
+ placeholder="选择 Topic"
+ style={{ width: 360 }}
+ value={selectedTopic}
+ onChange={setSelectedTopic}
+ allowClear
+ showSearch
+ options={topicOptions.map((t) => ({
+ value: t,
+ label: t,
+ }))}
+ />
+ <Input
+ placeholder="输入 Message ID"
+ style={{ width: 400 }}
+ value={msgIdInput}
+ onChange={(e) => setMsgIdInput(e.target.value)}
+ />
+ </>
)}
<Button
diff --git a/web/src/pages/studio/Producer.tsx
b/web/src/pages/studio/Producer.tsx
index e0cfc486..132ccd37 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -60,10 +60,26 @@ const ProducerPage = () => {
};
}, []);
+ const handleInstanceChange = (instanceId: string) => {
+ setSelectedInstanceId(instanceId);
+ setTopicList([]);
+ setProducerGroups([]);
+ setConnectionList([]);
+ form.setFieldsValue({ selectedTopic: undefined, producerGroup: undefined
});
+ };
+
useEffect(() => {
let cancelled = false;
- void fetchTopicList()
+ if (!selectedInstanceId) {
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ form.setFieldsValue({ selectedTopic: undefined, producerGroup: undefined
});
+
+ void fetchTopicList(selectedInstanceId)
.then((topics) => {
if (!cancelled) {
setTopicList(topics);
@@ -78,7 +94,7 @@ const ProducerPage = () => {
return () => {
cancelled = true;
};
- }, [fetchTopicFailedMessage, message]);
+ }, [fetchTopicFailedMessage, form, message, selectedInstanceId]);
useEffect(() => {
let cancelled = false;
@@ -170,7 +186,7 @@ const ProducerPage = () => {
<Select
aria-label="Instance"
value={selectedInstanceId || undefined}
- onChange={setSelectedInstanceId}
+ onChange={handleInstanceChange}
placeholder="Select instance"
style={{ width: 220 }}
options={instances.map((instance) => ({ value: instance.id,
label: instance.name }))}
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx
b/web/src/pages/studio/__tests__/Producer.test.tsx
index 8d43bee5..5bc65599 100644
--- a/web/src/pages/studio/__tests__/Producer.test.tsx
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -16,7 +16,7 @@
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
@@ -87,7 +87,7 @@ describe('ProducerPage', () => {
renderWithProviders(<ProducerPage />);
await waitFor(() => {
- expect(fetchTopicList).toHaveBeenCalledTimes(1);
+ expect(fetchTopicList).toHaveBeenCalledWith('instance-1');
});
});
@@ -186,4 +186,64 @@ describe('ProducerPage', () => {
);
});
});
+
+ it('clears stale producer query state before loading a new instance', async
() => {
+ vi.mocked(listInstances).mockResolvedValue([
+ {
+ id: 'instance-1',
+ name: 'Primary instance',
+ remark: '',
+ type: 'DIRECT',
+ endpoint: '127.0.0.1:9876',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '2026-08-01T00:00:00',
+ updatedAt: '2026-08-01T00:00:00',
+ },
+ {
+ id: 'instance-2',
+ name: 'Secondary instance',
+ remark: '',
+ type: 'DIRECT',
+ endpoint: '127.0.0.2:9876',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '2026-08-01T00:00:00',
+ updatedAt: '2026-08-01T00:00:00',
+ },
+ ]);
+
vi.mocked(fetchTopicList).mockResolvedValueOnce(['order-events']).mockResolvedValueOnce([]);
+ vi.mocked(queryProducerConnection).mockResolvedValue([
+ {
+ clientId: 'producer-1',
+ clientAddr: '192.168.1.10',
+ language: 'JAVA',
+ versionDesc: '5.1.0',
+ },
+ ]);
+ const user = userEvent.setup();
+ const { container } = renderWithProviders(<ProducerPage />);
+
+ await waitFor(() =>
expect(fetchTopicList).toHaveBeenCalledWith('instance-1'));
+ const [instanceSelect, topicSelect, groupInput] =
screen.getAllByRole('combobox');
+ fireEvent.mouseDown(topicSelect.parentElement!);
+ await user.click(
+ await screen.findByText('order-events', { selector:
'.ant-select-item-option-content' }),
+ );
+ await user.type(groupInput, 'order-producer');
+ await user.click(screen.getByRole('button', { name: /搜索/ }));
+ expect(await screen.findByText('producer-1')).toBeInTheDocument();
+
+ fireEvent.mouseDown(instanceSelect.parentElement!);
+ await user.click(
+ await screen.findByText('Secondary instance', {
+ selector: '.ant-select-item-option-content',
+ }),
+ );
+
+ await waitFor(() =>
expect(fetchTopicList).toHaveBeenLastCalledWith('instance-2'));
+ // scope to the page container: antd keeps closed dropdown portals in
document.body
+
expect(within(container).queryByText('producer-1')).not.toBeInTheDocument();
+
expect(within(container).queryByText('order-events')).not.toBeInTheDocument();
+ });
});