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 c406a0cc0 fix(server): normalize data entries and harden request 
boundaries (#2243)
c406a0cc0 is described below

commit c406a0cc074461f1c067208a070056f45bbcba3a
Author: btlqql <[email protected]>
AuthorDate: Wed Aug 19 13:54:26 2026 +0800

    fix(server): normalize data entries and harden request boundaries (#2243)
    
    * fix(metrics): normalize data source configuration
    
    * fix(cluster): tolerate malformed topology entries
    
    * fix(topic): decode route permission bits
    
    * fix: isolate malformed remote ACL rows
    
    * fix: canonicalize alert channel entries
    
    * fix(security): reject IPv6 unique-local addresses in SSRF guard
    
    InetAddress does not classify fc00::/7 as link-local or site-local, so
    unique-local addresses (notably the AWS EC2 IMDS IPv6 endpoint
    fd00:ec2::254) slipped through UrlHostGuard. Add an explicit ULA check to
    areAllowed so these are rejected even when loopback is permitted.
    
    * fix(common): map unmapped routes and wrong methods to 404/405 instead of 
500
    
    Spring MVC throws NoResourceFoundException for unmatched routes and
    HttpRequestMethodNotSupportedException for wrong methods; both fell through
    to the generic catch-all handler and were reported as HTTP 500. Add explicit
    handlers so they return 404/405 (and 415 for unsupported media types) in the
    standard Result envelope.
    
    * fix(dashboard): calculate today message delta
---
 .../studio/cluster/broker/RealClusterProvider.java | 19 +++++-
 .../studio/cluster/metrics/MetricsBackendType.java | 30 ++++++----
 .../common/exception/GlobalExceptionHandler.java   | 37 ++++++++++++
 .../rocketmq/studio/common/util/UrlHostGuard.java  | 16 +++++-
 .../studio/instance/acl/ApacheAclReadService.java  | 16 +++++-
 .../studio/instance/acl/RemoteAclPolicyVO.java     |  7 ++-
 .../studio/ops/alert/AlertRuleRequestDTO.java      | 15 ++++-
 .../ops/alert/MybatisPlusAlertRepository.java      | 15 +++--
 .../provider/apache/RocketMQDashboardProvider.java | 28 ++++++---
 .../provider/apache/RocketMQMetadataProvider.java  | 13 ++---
 .../rocketmq/studio/settings/DataSourceDTO.java    |  9 ++-
 .../cluster/broker/RealClusterProviderTest.java    | 40 +++++++++++++
 .../cluster/metrics/MetricsBackendTypeTest.java    | 11 ++++
 .../exception/GlobalExceptionHandlerTest.java      | 23 ++++++++
 .../studio/common/util/UrlHostGuardTest.java       | 23 ++++++++
 .../instance/acl/ApacheAclReadServiceTest.java     | 67 +++++++++++++++++++++-
 .../studio/ops/alert/AlertRuleRequestDTOTest.java  | 51 ++++++++++++++++
 .../ops/alert/MybatisPlusAlertRepositoryTest.java  | 27 +++++++++
 .../apache/RocketMQDashboardProviderTest.java      | 48 +++++++++++++++-
 .../apache/RocketMQMetadataProviderTest.java       | 43 ++++++++++++++
 .../studio/settings/DataSourceDTOTest.java         | 67 ++++++++++++++++++++++
 21 files changed, 562 insertions(+), 43 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
index c164a788e..be27a4d86 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProvider.java
@@ -35,6 +35,7 @@ import java.util.Collection;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 /**
@@ -118,15 +119,24 @@ public class RealClusterProvider implements 
ClusterProvider {
                 clusterInfo.getClusterAddrTable() == null ? Map.of() : 
clusterInfo.getClusterAddrTable();
 
         if (clusterAddrTable.isEmpty()) {
-            return List.of(toClusterVO(namesrvAddr, "DefaultCluster", 
brokerAddrTable.values()));
+            List<BrokerData> brokers = brokerAddrTable.values().stream()
+                    .filter(Objects::nonNull)
+                    .toList();
+            return brokers.isEmpty()
+                    ? List.of()
+                    : List.of(toClusterVO(namesrvAddr, "DefaultCluster", 
brokers));
         }
 
         return clusterAddrTable.entrySet().stream()
+                .filter(entry -> entry.getKey() != null && 
!entry.getKey().isBlank())
+                .filter(entry -> entry.getValue() != null)
                 .sorted(Map.Entry.comparingByKey())
                 .map(entry -> toClusterVO(namesrvAddr, entry.getKey(), 
entry.getValue().stream()
+                        .filter(Objects::nonNull)
                         .map(brokerAddrTable::get)
-                        .filter(java.util.Objects::nonNull)
+                        .filter(Objects::nonNull)
                         .toList()))
+                .filter(cluster -> !cluster.getBrokers().isEmpty())
                 .toList();
     }
 
@@ -166,7 +176,10 @@ public class RealClusterProvider implements 
ClusterProvider {
         if (data.getBrokerAddrs() != null) {
             addr = data.getBrokerAddrs().get(MixAll.MASTER_ID);
             if (addr == null) {
-                addr = 
data.getBrokerAddrs().values().stream().findFirst().orElse(null);
+                addr = data.getBrokerAddrs().values().stream()
+                        .filter(Objects::nonNull)
+                        .findFirst()
+                        .orElse(null);
             }
         }
         return BrokerVO.builder()
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
index e30b8c021..f73a3c6a6 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendType.java
@@ -29,22 +29,29 @@ import java.util.Locale;
  */
 public enum MetricsBackendType {
 
-    PROMETHEUS("/api/v1/query_range", "/api/v1/query"),
-    VICTORIA_METRICS("/select/0/prometheus/api/v1/query_range", 
"/select/0/prometheus/api/v1/query"),
-    THANOS("/api/v1/query_range", "/api/v1/query"),
-    CORTEX("/api/v1/query_range", "/api/v1/query"),
-    MIMIR("/prometheus/api/v1/query_range", "/prometheus/api/v1/query"),
-    ARMS("/api/v1/query_range", "/api/v1/query"),
-    CUSTOM("/api/v1/query_range", "/api/v1/query");
+    PROMETHEUS("Prometheus", "/api/v1/query_range", "/api/v1/query"),
+    VICTORIA_METRICS(
+            "VictoriaMetrics", "/select/0/prometheus/api/v1/query_range", 
"/select/0/prometheus/api/v1/query"),
+    THANOS("Thanos", "/api/v1/query_range", "/api/v1/query"),
+    CORTEX("Cortex", "/api/v1/query_range", "/api/v1/query"),
+    MIMIR("Mimir", "/prometheus/api/v1/query_range", 
"/prometheus/api/v1/query"),
+    ARMS("ARMS", "/api/v1/query_range", "/api/v1/query"),
+    CUSTOM("Custom", "/api/v1/query_range", "/api/v1/query");
 
+    private final String providerType;
     private final String queryPath;
     private final String instantQueryPath;
 
-    MetricsBackendType(String queryPath, String instantQueryPath) {
+    MetricsBackendType(String providerType, String queryPath, String 
instantQueryPath) {
+        this.providerType = providerType;
         this.queryPath = queryPath;
         this.instantQueryPath = instantQueryPath;
     }
 
+    public String getProviderType() {
+        return providerType;
+    }
+
     public String getQueryPath() {
         return queryPath;
     }
@@ -61,9 +68,12 @@ public enum MetricsBackendType {
         if (providerType == null) {
             return PROMETHEUS;
         }
-        return switch (providerType.trim().toUpperCase(Locale.ROOT)) {
+        String normalizedProviderType = providerType.trim()
+                .replaceAll("[\\s_-]+", "")
+                .toUpperCase(Locale.ROOT);
+        return switch (normalizedProviderType) {
             case "PROMETHEUS" -> PROMETHEUS;
-            case "VICTORIAMETRICS", "VICTORIA_METRICS", "VICTORIA" -> 
VICTORIA_METRICS;
+            case "VICTORIAMETRICS", "VICTORIA" -> VICTORIA_METRICS;
             case "THANOS" -> THANOS;
             case "CORTEX" -> CORTEX;
             case "MIMIR" -> MIMIR;
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
 
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
index 384c16a61..e0b3c33f0 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandler.java
@@ -22,12 +22,15 @@ import 
org.apache.rocketmq.studio.ops.ai.LlmGatewayException;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpMediaTypeNotSupportedException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
 import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.MissingServletRequestParameterException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 import 
org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
 import lombok.extern.slf4j.Slf4j;
 
 @Slf4j
@@ -92,4 +95,38 @@ public class GlobalExceptionHandler {
         log.error("Unexpected exception", ex);
         return Result.error(500, "Internal Server Error");
     }
+
+    /**
+     * Spring MVC throws {@link NoResourceFoundException} for unmatched 
routes. Without an
+     * explicit handler it would fall through to the generic catch-all and be 
reported as
+     * HTTP 500 instead of the correct 404.
+     */
+    @ExceptionHandler(NoResourceFoundException.class)
+    @ResponseStatus(HttpStatus.NOT_FOUND)
+    public Result<?> handleNoResourceFoundException(NoResourceFoundException 
ex) {
+        log.warn("Resource not found: {}", ex.getMessage());
+        return Result.error(HttpStatus.NOT_FOUND.value(), ex.getMessage());
+    }
+
+    /**
+     * Requests with an unsupported HTTP method must be reported as 405, not 
500.
+     */
+    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
+    public Result<?> handleHttpRequestMethodNotSupportedException(
+            HttpRequestMethodNotSupportedException ex) {
+        log.warn("Method not allowed: {}", ex.getMessage());
+        return Result.error(HttpStatus.METHOD_NOT_ALLOWED.value(), 
ex.getMessage());
+    }
+
+    /**
+     * Requests with an unsupported content type must be reported as 415, not 
500.
+     */
+    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
+    @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
+    public Result<?> handleHttpMediaTypeNotSupportedException(
+            HttpMediaTypeNotSupportedException ex) {
+        log.warn("Unsupported media type: {}", ex.getMessage());
+        return Result.error(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value(), 
ex.getMessage());
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/common/util/UrlHostGuard.java 
b/server/src/main/java/org/apache/rocketmq/studio/common/util/UrlHostGuard.java
index 43c9ef3d3..289b55a9c 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/common/util/UrlHostGuard.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/common/util/UrlHostGuard.java
@@ -112,7 +112,7 @@ public final class UrlHostGuard {
         }
         for (InetAddress address : addresses) {
             if (address == null || address.isAnyLocalAddress() || 
address.isLinkLocalAddress()
-                    || address.isMulticastAddress()) {
+                    || address.isMulticastAddress() || 
isUniqueLocalAddress(address)) {
                 return false;
             }
             if (address.isLoopbackAddress() && !allowLoopback) {
@@ -121,4 +121,18 @@ public final class UrlHostGuard {
         }
         return true;
     }
+
+    /**
+     * Whether the address is an IPv6 unique-local address (fc00::/7). Java's
+     * {@link InetAddress} does not classify these as link-local or 
site-local, so they would
+     * otherwise pass the guard; notably {@code fd00:ec2::254} is the AWS EC2 
IMDS IPv6
+     * metadata endpoint and must not be reachable.
+     */
+    private static boolean isUniqueLocalAddress(InetAddress address) {
+        if (!(address instanceof java.net.Inet6Address)) {
+            return false;
+        }
+        byte[] bytes = address.getAddress();
+        return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC;
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
index 9edc9adde..78735a875 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadService.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.instance.acl;
 
 import lombok.RequiredArgsConstructor;
 import org.apache.rocketmq.common.MixAll;
+import org.apache.rocketmq.remoting.protocol.body.AclInfo;
 import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
 import org.apache.rocketmq.remoting.protocol.route.BrokerData;
 import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
@@ -26,6 +27,7 @@ import org.springframework.stereotype.Service;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 @Service
 @RequiredArgsConstructor
@@ -46,9 +48,7 @@ public class ApacheAclReadService {
                     continue;
                 }
                 try {
-                    policies.put(address, admin.listAcl(address, subject, 
resource).stream()
-                            .map(RemoteAclPolicyVO::from)
-                            .toList());
+                    policies.put(address, mapPolicies(admin.listAcl(address, 
subject, resource)));
                 } catch (Exception ex) {
                     failures.put(address, rootMessage(ex));
                 }
@@ -57,6 +57,16 @@ public class ApacheAclReadService {
         });
     }
 
+    private List<RemoteAclPolicyVO> mapPolicies(List<AclInfo> brokerPolicies) {
+        if (brokerPolicies == null) {
+            return List.of();
+        }
+        return brokerPolicies.stream()
+                .filter(Objects::nonNull)
+                .map(RemoteAclPolicyVO::from)
+                .toList();
+    }
+
     private RemoteAclReadResult result(Map<String, List<RemoteAclPolicyVO>> 
policies,
                                        Map<String, String> failures) {
         return RemoteAclReadResult.builder().source("APACHE_ACL2")
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/RemoteAclPolicyVO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/RemoteAclPolicyVO.java
index a7f25c228..a19a07892 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/RemoteAclPolicyVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/RemoteAclPolicyVO.java
@@ -19,12 +19,14 @@ package org.apache.rocketmq.studio.instance.acl;
 import org.apache.rocketmq.remoting.protocol.body.AclInfo;
 
 import java.util.List;
+import java.util.Objects;
 
 /** REST representation of an Apache ACL 2.0 policy. */
 public record RemoteAclPolicyVO(String subject, List<PolicyGroupVO> policies) {
 
     public static RemoteAclPolicyVO from(AclInfo policy) {
         List<PolicyGroupVO> groups = policy.getPolicies() == null ? List.of() 
: policy.getPolicies().stream()
+                .filter(Objects::nonNull)
                 .map(PolicyGroupVO::from)
                 .toList();
         return new RemoteAclPolicyVO(policy.getSubject(), groups);
@@ -33,6 +35,7 @@ public record RemoteAclPolicyVO(String subject, 
List<PolicyGroupVO> policies) {
     public record PolicyGroupVO(String policyType, List<PolicyEntryVO> 
entries) {
         private static PolicyGroupVO from(AclInfo.PolicyInfo policy) {
             List<PolicyEntryVO> policyEntries = policy.getEntries() == null ? 
List.of() : policy.getEntries().stream()
+                    .filter(Objects::nonNull)
                     .map(PolicyEntryVO::from)
                     .toList();
             return new PolicyGroupVO(policy.getPolicyType(), policyEntries);
@@ -46,7 +49,9 @@ public record RemoteAclPolicyVO(String subject, 
List<PolicyGroupVO> policies) {
         }
 
         private static List<String> listOrEmpty(List<String> values) {
-            return values == null ? List.of() : List.copyOf(values);
+            return values == null ? List.of() : values.stream()
+                    .filter(Objects::nonNull)
+                    .toList();
         }
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
index ec4d93a3f..52c73c5e6 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTO.java
@@ -34,7 +34,7 @@ public class AlertRuleRequestDTO {
     private String thresholdUnit;
     @Pattern(regexp = "(?:[0-9]+(?:ms|s|m|h|d|w|y))+", message = "duration is 
invalid")
     private String duration;
-    private List<String> channels;
+    private List<@NotBlank(message = "channel must not be blank") String> 
channels;
     private boolean enabled;
     private String description;
     private String brokerName;
@@ -52,7 +52,7 @@ public class AlertRuleRequestDTO {
                 .threshold(threshold)
                 .thresholdUnit(thresholdUnit)
                 .duration(duration)
-                .channels(channels)
+                .channels(normalizeChannels(channels))
                 .enabled(enabled)
                 .description(description)
                 .brokerName(brokerName)
@@ -60,4 +60,15 @@ public class AlertRuleRequestDTO {
                 .severity(severity)
                 .build();
     }
+
+    private static List<String> normalizeChannels(List<String> values) {
+        if (values == null) {
+            return null;
+        }
+        return values.stream()
+                .filter(value -> value != null && !value.isBlank())
+                .map(String::trim)
+                .distinct()
+                .toList();
+    }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
index 97c4822fc..c3c6d4538 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
@@ -127,7 +127,9 @@ public class MybatisPlusAlertRepository implements 
AlertRepository {
         entity.setThreshold(rule.getThreshold());
         entity.setThresholdUnit(rule.getThresholdUnit());
         entity.setDuration(rule.getDuration());
-        entity.setChannels(rule.getChannels() == null ? null : 
String.join(",", rule.getChannels()));
+        entity.setChannels(rule.getChannels() == null
+                ? null
+                : String.join(",", normalizeChannels(rule.getChannels())));
         entity.setEnabled(rule.isEnabled());
         entity.setLastTriggered(rule.getLastTriggered());
         entity.setDescription(rule.getDescription());
@@ -176,9 +178,14 @@ public class MybatisPlusAlertRepository implements 
AlertRepository {
         if (!StringUtils.hasText(value)) {
             return List.of();
         }
-        return Arrays.stream(value.split(","))
+        return normalizeChannels(Arrays.asList(value.split(",")));
+    }
+
+    private static List<String> normalizeChannels(List<String> channels) {
+        return channels.stream()
+                .filter(StringUtils::hasText)
                 .map(String::trim)
-                .filter(part -> !part.isEmpty())
-                .collect(Collectors.toList());
+                .distinct()
+                .toList();
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
index dc3699d5f..08fd392b7 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProvider.java
@@ -235,13 +235,7 @@ public class RocketMQDashboardProvider implements 
DashboardProvider {
                         tpsIn += parseTps(table.get("putTps"));
                         tpsOut += parseTps(table.get("getTransferredTps"));
 
-                        String msgPutToday = 
table.get("msgPutTotalTodayMorning");
-                        if (msgPutToday != null) {
-                            try {
-                                messagesToday += 
Long.parseLong(msgPutToday.trim());
-                            } catch (NumberFormatException ignored) {
-                            }
-                        }
+                        messagesToday += parseMessagesToday(table);
                     }
                 } catch (Exception e) {
                     log.warn("Failed to get runtime info from broker {}: {}", 
brokerAddr, e.getMessage());
@@ -385,6 +379,26 @@ public class RocketMQDashboardProvider implements 
DashboardProvider {
         return 0;
     }
 
+    private long parseMessagesToday(Map<String, String> runtimeStats) {
+        String morningValue = runtimeStats.get("msgPutTotalTodayMorning");
+        String currentValue = runtimeStats.get("msgPutTotalTodayNow");
+        if (morningValue == null || currentValue == null) {
+            return 0;
+        }
+        try {
+            long morning = Long.parseLong(morningValue.trim());
+            long current = Long.parseLong(currentValue.trim());
+            if (morning < 0 || current < 0) {
+                return 0;
+            }
+            return Math.max(0, current - morning);
+        } catch (NumberFormatException exception) {
+            log.debug("Failed to parse today's message counters: morning={}, 
current={}",
+                    morningValue, currentValue);
+            return 0;
+        }
+    }
+
     private boolean isSystemTopic(String topic) {
         return SystemTopicFilter.isSystem(topic);
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
index 1b6986c2c..df46661ed 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProvider.java
@@ -18,10 +18,10 @@ package org.apache.rocketmq.studio.provider.apache;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.common.constant.PermName;
 import org.apache.rocketmq.common.message.MessageQueue;
 import org.apache.rocketmq.common.MixAll;
 import org.apache.rocketmq.common.TopicConfig;
-import org.apache.rocketmq.common.constant.PermName;
 import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
 import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
 import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
@@ -568,14 +568,13 @@ public class RocketMQMetadataProvider implements 
MetadataProvider {
     }
 
     private TopicPerm mapPerm(int perm) {
-        // RocketMQ perm: 6=RW, 4=R, 2=W
-        if (perm == 6) {
-            return TopicPerm.RW;
-        } else if (perm == 4) {
-            return TopicPerm.RO;
-        } else if (perm == 2) {
+        if (PermName.isReadable(perm)) {
+            return PermName.isWriteable(perm) ? TopicPerm.RW : TopicPerm.RO;
+        }
+        if (PermName.isWriteable(perm)) {
             return TopicPerm.WO;
         }
+        // TopicPerm has no inaccessible value; retain the historical fallback 
for invalid masks.
         return TopicPerm.RW;
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceDTO.java 
b/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceDTO.java
index ff7fb9e83..25b6752f6 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/settings/DataSourceDTO.java
@@ -19,6 +19,7 @@ package org.apache.rocketmq.studio.settings;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.Pattern;
 import lombok.Data;
+import org.apache.rocketmq.studio.cluster.metrics.MetricsBackendType;
 
 import java.util.List;
 
@@ -44,16 +45,18 @@ public class DataSourceDTO {
             message = "Unsupported metrics data source authentication")
     private String auth;
 
-    private List<String> instanceIds;
+    private List<@NotBlank(message = "instanceIds must not contain blank 
values") String> instanceIds;
 
     public DataSourceVO toDataSourceVO() {
         return DataSourceVO.builder()
                 .key(key)
                 .name(name)
-                .type(type.trim())
+                
.type(MetricsBackendType.fromProviderType(type).getProviderType())
                 .url(url)
                 .auth(auth == null ? null : auth.trim())
-                .instanceIds(instanceIds)
+                .instanceIds(instanceIds == null
+                        ? null
+                        : 
instanceIds.stream().map(String::trim).distinct().toList())
                 .build();
     }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
index 56ff3a9d6..82b5a7d75 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/RealClusterProviderTest.java
@@ -29,6 +29,7 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 
@@ -135,6 +136,45 @@ class RealClusterProviderTest {
         assertThat(provider.describeClusters("10.0.0.1:9876")).isEmpty();
     }
 
+    @Test
+    void describeClustersShouldSkipMalformedTopologyEntries() throws Exception 
{
+        ClusterInfo info = new ClusterInfo();
+        HashMap<String, BrokerData> brokers = new HashMap<>();
+        brokers.put("broker-a", new BrokerData("ValidCluster", "broker-a",
+                new HashMap<>(java.util.Map.of(0L, "10.0.0.11:10911"))));
+        brokers.put("broken", null);
+        info.setBrokerAddrTable(brokers);
+
+        HashSet<String> brokerNames = new HashSet<>();
+        brokerNames.add("broker-a");
+        brokerNames.add("missing");
+        brokerNames.add(null);
+        HashMap<String, Set<String>> clusters = new HashMap<>();
+        clusters.put("ValidCluster", brokerNames);
+        clusters.put("NullSetCluster", null);
+        clusters.put(null, Set.of("broker-a"));
+        info.setClusterAddrTable(clusters);
+        stubClusterInfo("10.0.0.1:9876", info);
+
+        List<ClusterVO> result = provider.describeClusters("10.0.0.1:9876");
+
+        
assertThat(result).extracting(ClusterVO::getName).containsExactly("ValidCluster");
+        assertThat(result.get(0).getBrokers()).extracting(BrokerVO::getName)
+                .containsExactly("broker-a");
+    }
+
+    @Test
+    void describeClustersShouldReturnEmptyWhenTopologyHasNoValidBrokers() 
throws Exception {
+        ClusterInfo info = new ClusterInfo();
+        HashMap<String, BrokerData> brokers = new HashMap<>();
+        brokers.put("broken", null);
+        info.setBrokerAddrTable(brokers);
+        info.setClusterAddrTable(new HashMap<>());
+        stubClusterInfo("10.0.0.1:9876", info);
+
+        assertThat(provider.describeClusters("10.0.0.1:9876")).isEmpty();
+    }
+
     @Test
     void discoverClustersShouldUseConfiguredNamesrv() throws Exception {
         properties.setNamesrvAddr("10.0.0.1:9876");
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
index 2054bb661..d6dbcd7d6 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/MetricsBackendTypeTest.java
@@ -31,6 +31,10 @@ class MetricsBackendTypeTest {
                 .isEqualTo(MetricsBackendType.VICTORIA_METRICS);
         assertThat(MetricsBackendType.fromProviderType("VICTORIA_METRICS"))
                 .isEqualTo(MetricsBackendType.VICTORIA_METRICS);
+        assertThat(MetricsBackendType.fromProviderType("victoria metrics"))
+                .isEqualTo(MetricsBackendType.VICTORIA_METRICS);
+        assertThat(MetricsBackendType.fromProviderType("victoria-metrics"))
+                .isEqualTo(MetricsBackendType.VICTORIA_METRICS);
         
assertThat(MetricsBackendType.fromProviderType("THANOS")).isEqualTo(MetricsBackendType.THANOS);
         
assertThat(MetricsBackendType.fromProviderType("CORTEX")).isEqualTo(MetricsBackendType.CORTEX);
         
assertThat(MetricsBackendType.fromProviderType("MIMIR")).isEqualTo(MetricsBackendType.MIMIR);
@@ -71,6 +75,13 @@ class MetricsBackendTypeTest {
         
assertThat(MetricsBackendType.ARMS.getQueryPath()).isEqualTo("/api/v1/query_range");
     }
 
+    @Test
+    void shouldExposeCanonicalProviderType() {
+        
assertThat(MetricsBackendType.PROMETHEUS.getProviderType()).isEqualTo("Prometheus");
+        
assertThat(MetricsBackendType.VICTORIA_METRICS.getProviderType()).isEqualTo("VictoriaMetrics");
+        
assertThat(MetricsBackendType.ARMS.getProviderType()).isEqualTo("ARMS");
+    }
+
     @Test
     void shouldExposeDistinctInstantQueryPathsForBackends() {
         
assertThat(MetricsBackendType.PROMETHEUS.getInstantQueryPath()).isEqualTo("/api/v1/query");
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
index bfddc1142..af2b16961 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/common/exception/GlobalExceptionHandlerTest.java
@@ -65,6 +65,29 @@ class GlobalExceptionHandlerTest {
                 .andExpect(jsonPath("$.message").value("LLM provider request 
timed out"));
     }
 
+    @Test
+    void unmappedRouteReturns404WithEnvelopeInsteadOf500() {
+        // Standalone MockMvc does not synthesize NoResourceFoundException for 
unmapped
+        // routes (only a full Spring MVC resource resolver does), so verify 
the handler
+        // mapping directly: NoResourceFoundException must map to a 404 Result 
envelope
+        // rather than falling through to the generic 500 catch-all.
+        GlobalExceptionHandler handler = new GlobalExceptionHandler();
+        org.springframework.web.servlet.resource.NoResourceFoundException ex =
+                new 
org.springframework.web.servlet.resource.NoResourceFoundException(
+                        org.springframework.http.HttpMethod.GET, 
"missing-resource");
+        org.apache.rocketmq.studio.common.domain.Result<?> result =
+                handler.handleNoResourceFoundException(ex);
+        
org.assertj.core.api.Assertions.assertThat(result.getCode()).isEqualTo(404);
+    }
+
+    @Test
+    void wrongHttpMethodReturns405WithEnvelopeInsteadOf500() throws Exception {
+        
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+                        .post("/test/business/400"))
+                .andExpect(status().isMethodNotAllowed())
+                .andExpect(jsonPath("$.code").value(405));
+    }
+
     @RestController
     static class FailingController {
 
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/common/util/UrlHostGuardTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/common/util/UrlHostGuardTest.java
index f31ee2653..313982022 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/common/util/UrlHostGuardTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/common/util/UrlHostGuardTest.java
@@ -40,4 +40,27 @@ class UrlHostGuardTest {
 
         assertThat(UrlHostGuard.areAllowed(new InetAddress[]{first, second}, 
false)).isTrue();
     }
+
+    @Test
+    void areAllowedShouldRejectIpv6UniqueLocalAddresses() throws Exception {
+        // fc00::/7 unique-local addresses are not classified as 
link/site-local by the JDK
+        // and would otherwise pass the guard; fd00:ec2::254 is the AWS EC2 
IMDS IPv6 endpoint.
+        InetAddress ula = InetAddress.getByName("fd00:ec2::254");
+        InetAddress ulaFc = InetAddress.getByName("fc00::1");
+
+        assertThat(UrlHostGuard.areAllowed(new InetAddress[]{ula}, 
false)).isFalse();
+        assertThat(UrlHostGuard.areAllowed(new InetAddress[]{ulaFc}, 
false)).isFalse();
+    }
+
+    @Test
+    void areAllowedShouldStillAcceptGlobalIpv6Addresses() throws Exception {
+        InetAddress globalIpv6 = InetAddress.getByName("2606:4700:4700::1111");
+
+        assertThat(UrlHostGuard.areAllowed(new InetAddress[]{globalIpv6}, 
false)).isTrue();
+    }
+
+    @Test
+    void isAllowedHostShouldRejectIpv6UlaLiteralEvenWhenLoopbackIsAllowed() {
+        assertThat(UrlHostGuard.isAllowedHost("fd00:ec2::254", 
true)).isFalse();
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadServiceTest.java
index 3bf178b53..51c79466b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/ApacheAclReadServiceTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.instance.acl;
 
+import org.apache.rocketmq.remoting.protocol.body.AclInfo;
 import org.apache.rocketmq.remoting.protocol.body.ClusterInfo;
 import org.apache.rocketmq.remoting.protocol.route.BrokerData;
 import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
@@ -23,8 +24,9 @@ import 
org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
 import org.apache.rocketmq.tools.admin.MQAdminExt;
 import org.junit.jupiter.api.Test;
 
-import java.util.List;
+import java.util.Arrays;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -58,4 +60,67 @@ class ApacheAclReadServiceTest {
         
assertThat(result.getFailuresByBroker()).containsEntry("broker-b:10911", 
"unavailable");
         assertThat(result.isPartial()).isTrue();
     }
+
+    @Test
+    void treatsNullBrokerPolicyListAsEmptySuccess() throws Exception {
+        RuntimeAdminClientResolver resolver = 
mock(RuntimeAdminClientResolver.class);
+        MQAdminExt admin = mock(MQAdminExt.class);
+        
when(admin.examineBrokerClusterInfo()).thenReturn(clusterInfo("broker-a:10911"));
+        when(admin.listAcl("broker-a:10911", null, null)).thenReturn(null);
+        executeWith(resolver, admin);
+
+        RemoteAclReadResult result = new 
ApacheAclReadService(resolver).listRules("instance-1", null, null);
+
+        
assertThat(result.getPoliciesByBroker()).containsEntry("broker-a:10911", 
List.of());
+        assertThat(result.getFailuresByBroker()).isEmpty();
+        assertThat(result.isPartial()).isFalse();
+    }
+
+    @Test
+    void retainsValidPoliciesWhenResponseContainsNullRows() throws Exception {
+        RuntimeAdminClientResolver resolver = 
mock(RuntimeAdminClientResolver.class);
+        MQAdminExt admin = mock(MQAdminExt.class);
+        AclInfo.PolicyEntryInfo entry = AclInfo.PolicyEntryInfo.of(
+                "Topic:orders", Arrays.asList("PUB", null), 
List.of("10.0.0.0/8"), "ALLOW");
+        AclInfo.PolicyInfo group = new AclInfo.PolicyInfo();
+        group.setPolicyType("CUSTOM");
+        group.setEntries(Arrays.asList(entry, null));
+        AclInfo policy = new AclInfo();
+        policy.setSubject("User:alice");
+        policy.setPolicies(Arrays.asList(group, null));
+        
when(admin.examineBrokerClusterInfo()).thenReturn(clusterInfo("broker-a:10911"));
+        when(admin.listAcl("broker-a:10911", null, 
null)).thenReturn(Arrays.asList(policy, null));
+        executeWith(resolver, admin);
+
+        RemoteAclReadResult result = new 
ApacheAclReadService(resolver).listRules("instance-1", null, null);
+
+        assertThat(result.getFailuresByBroker()).isEmpty();
+        assertThat(result.getPoliciesByBroker().get("broker-a:10911"))
+                .singleElement()
+                .satisfies(remotePolicy -> {
+                    assertThat(remotePolicy.subject()).isEqualTo("User:alice");
+                    
assertThat(remotePolicy.policies()).singleElement().satisfies(remoteGroup -> {
+                        
assertThat(remoteGroup.policyType()).isEqualTo("CUSTOM");
+                        
assertThat(remoteGroup.entries()).singleElement().satisfies(remoteEntry -> {
+                            
assertThat(remoteEntry.resource()).isEqualTo("Topic:orders");
+                            
assertThat(remoteEntry.actions()).containsExactly("PUB");
+                        });
+                    });
+                });
+    }
+
+    private ClusterInfo clusterInfo(String address) {
+        BrokerData broker = new BrokerData();
+        broker.setBrokerAddrs(new HashMap<>(Map.of(0L, address)));
+        ClusterInfo clusterInfo = new ClusterInfo();
+        clusterInfo.setBrokerAddrTable(Map.of("broker", broker));
+        return clusterInfo;
+    }
+
+    private void executeWith(RuntimeAdminClientResolver resolver, MQAdminExt 
admin) throws Exception {
+        when(resolver.execute(eq("instance-1"), any())).thenAnswer(invocation 
-> {
+            MqAdminExtFactory.AdminAction<?> action = 
invocation.getArgument(1);
+            return action.apply(admin);
+        });
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java
new file mode 100644
index 000000000..09d0dfbdd
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleRequestDTOTest.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AlertRuleRequestDTOTest {
+
+    private final Validator validator = 
Validation.buildDefaultValidatorFactory().getValidator();
+
+    @Test
+    void channelsShouldRejectNullAndBlankElements() {
+        AlertRuleRequestDTO request = new AlertRuleRequestDTO();
+        request.setName("High Lag");
+        request.setChannels(Arrays.asList("email", null, " "));
+
+        assertThat(validator.validate(request))
+                .extracting(violation -> violation.getMessage())
+                .containsOnly("channel must not be blank");
+    }
+
+    @Test
+    void toAlertRuleVOShouldTrimAndDeduplicateChannelsInInputOrder() {
+        AlertRuleRequestDTO request = new AlertRuleRequestDTO();
+        request.setName("High Lag");
+        request.setChannels(List.of(" email ", "sms", "email", " sms "));
+
+        
assertThat(request.toAlertRuleVO().getChannels()).containsExactly("email", 
"sms");
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
index ee945a647..0b81c7e4c 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
@@ -28,6 +28,7 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
 
@@ -70,6 +71,32 @@ class MybatisPlusAlertRepositoryTest {
                         
org.apache.rocketmq.studio.common.domain.enums.AlertLevel.warning));
     }
 
+    @Test
+    void saveRuleShouldCanonicalizeChannelsBeforePersistence() {
+        AlertRuleVO rule = AlertRuleVO.builder()
+                .id(1L)
+                .name("Lag")
+                .channels(Arrays.asList(" email ", null, "", "sms", "email", " 
sms "))
+                .build();
+
+        repository.saveRule(rule);
+
+        verify(ruleMapper).insert(argThat((RmqAlertRule entity) ->
+                entity != null && "email,sms".equals(entity.getChannels())));
+    }
+
+    @Test
+    void findAllRulesShouldCanonicalizeLegacyStoredChannels() {
+        RmqAlertRule entity = new RmqAlertRule();
+        entity.setId(1L);
+        entity.setName("Lag");
+        entity.setChannels(" email ,,sms,email, sms ");
+        when(ruleMapper.selectList(any())).thenReturn(List.of(entity));
+
+        assertThat(repository.findAllRules()).singleElement()
+                .satisfies(rule -> 
assertThat(rule.getChannels()).containsExactly("email", "sms"));
+    }
+
     @Test
     void acknowledgeAlertShouldReportUpdateOutcomeWithoutInserting() {
         SystemAlertVO deleted = 
SystemAlertVO.builder().id(1L).acknowledged(true).build();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
index e12515fcd..6f5b4d516 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDashboardProviderTest.java
@@ -436,6 +436,46 @@ class RocketMQDashboardProviderTest {
         });
     }
 
+    @Test
+    void dashboardShouldReportMessagesProducedSinceTodayMorning() throws 
Exception {
+        DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithTwoMasters());
+        when(adminExt.fetchBrokerRuntimeStats("10.0.0.11:10911"))
+                .thenReturn(runtimeStats("2.0", "5.0", "1000", "1250"));
+        when(adminExt.fetchBrokerRuntimeStats("10.0.0.12:10911"))
+                .thenReturn(runtimeStats("2.0", "5.0", "400", "475"));
+
+        DashboardDataVO dashboard = newProvider(adminExt).getDashboardData();
+
+        
assertThat(dashboard.getStats().getTotalMessagesToday()).isEqualTo(325L);
+    }
+
+    @Test
+    void 
dashboardShouldNotReportNegativeCountWhenBrokerCounterMovesBackwards() throws 
Exception {
+        DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
+        when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfo());
+        when(adminExt.fetchBrokerRuntimeStats("10.0.0.11:10911"))
+                .thenReturn(runtimeStats("2.0", "5.0", "1000", "50"));
+
+        DashboardDataVO dashboard = newProvider(adminExt).getDashboardData();
+
+        assertThat(dashboard.getStats().getTotalMessagesToday()).isZero();
+    }
+
+    @Test
+    void dashboardShouldIgnoreMalformedTodayMessageCounters() throws Exception 
{
+        DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithTwoMasters());
+        when(adminExt.fetchBrokerRuntimeStats("10.0.0.11:10911"))
+                .thenReturn(runtimeStats("2.0", "5.0", "invalid", "1250"));
+        when(adminExt.fetchBrokerRuntimeStats("10.0.0.12:10911"))
+                .thenReturn(runtimeStats("2.0", "5.0", "400", "invalid"));
+
+        DashboardDataVO dashboard = newProvider(adminExt).getDashboardData();
+
+        assertThat(dashboard.getStats().getTotalMessagesToday()).isZero();
+    }
+
     @Test
     void dashboardShouldTolerateMissingTopicListAndClusterMembership() throws 
Exception {
         DefaultMQAdminExt adminExt = mock(DefaultMQAdminExt.class);
@@ -534,11 +574,17 @@ class RocketMQDashboardProviderTest {
     }
 
     private KVTable runtimeStats(String putTps, String getTransferredTps) {
+        return runtimeStats(putTps, getTransferredTps, "42", "84");
+    }
+
+    private KVTable runtimeStats(String putTps, String getTransferredTps,
+                                 String todayMorning, String todayNow) {
         HashMap<String, String> table = new HashMap<>();
         table.put("brokerVersionDesc", "  V5_3_3  ");
         table.put("putTps", "1.0 " + putTps + " 3.0");
         table.put("getTransferredTps", "4.0 " + getTransferredTps + " 6.0");
-        table.put("msgPutTotalTodayMorning", "42");
+        table.put("msgPutTotalTodayMorning", todayMorning);
+        table.put("msgPutTotalTodayNow", todayNow);
 
         KVTable kvTable = new KVTable();
         kvTable.setTable(table);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
index 9baeeba4e..e6d5da09c 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMetadataProviderTest.java
@@ -21,6 +21,10 @@ import org.apache.rocketmq.common.message.MessageQueue;
 import org.apache.rocketmq.remoting.protocol.admin.ConsumeStats;
 import org.apache.rocketmq.remoting.protocol.admin.OffsetWrapper;
 import org.apache.rocketmq.remoting.protocol.body.GroupList;
+import org.apache.rocketmq.remoting.protocol.route.BrokerData;
+import org.apache.rocketmq.remoting.protocol.route.QueueData;
+import org.apache.rocketmq.remoting.protocol.route.TopicRouteData;
+import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
 import org.apache.rocketmq.studio.common.domain.enums.ConsumeType;
 import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -38,9 +42,12 @@ import 
org.apache.rocketmq.studio.persistence.mapper.RmqGroupMapper;
 import org.apache.rocketmq.studio.persistence.mapper.RmqTopicMapper;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.LinkedHashMap;
@@ -166,6 +173,42 @@ class RocketMQMetadataProviderTest {
         verify(runtimeAdminClientResolver).execute(eq("instance-a"), any());
     }
 
+    @ParameterizedTest
+    @CsvSource({
+        "0, RW",
+        "2, WO",
+        "3, WO",
+        "4, RO",
+        "5, RO",
+        "6, RW",
+        "7, RW"
+    })
+    void getTopicRoutesShouldInterpretPermissionBits(int permission, TopicPerm 
expected) throws Exception {
+        DefaultMQAdminExt admin = mock(DefaultMQAdminExt.class);
+        QueueData queueData = new QueueData();
+        queueData.setBrokerName("broker-a");
+        queueData.setReadQueueNums(4);
+        queueData.setWriteQueueNums(2);
+        queueData.setPerm(permission);
+        BrokerData brokerData = new BrokerData();
+        brokerData.setBrokerName("broker-a");
+        brokerData.setBrokerAddrs(new HashMap<>(Map.of(0L, "10.0.0.1:10911")));
+        TopicRouteData routeData = new TopicRouteData();
+        routeData.setQueueDatas(List.of(queueData));
+        routeData.setBrokerDatas(List.of(brokerData));
+        when(admin.examineTopicRouteInfo("TopicA")).thenReturn(routeData);
+
+        List<BrokerRouteVO> routes = 
newLiveProvider(admin).getTopicRoutes(null, "TopicA");
+
+        assertThat(routes).singleElement().satisfies(route -> {
+            assertThat(route.getBrokerName()).isEqualTo("broker-a");
+            assertThat(route.getBrokerAddr()).isEqualTo("10.0.0.1:10911");
+            assertThat(route.getReadQueues()).isEqualTo(4);
+            assertThat(route.getWriteQueues()).isEqualTo(2);
+            assertThat(route.getPerm()).isEqualTo(expected);
+        });
+    }
+
     @Test
     void getTopicConsumersShouldUseSelectedInstanceRuntimeClient() {
         TopicConsumerPageVO consumers = TopicConsumerPageVO.builder()
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/settings/DataSourceDTOTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/settings/DataSourceDTOTest.java
new file mode 100644
index 000000000..e54bf8751
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/settings/DataSourceDTOTest.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.settings;
+
+import jakarta.validation.Validation;
+import jakarta.validation.ValidatorFactory;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DataSourceDTOTest {
+
+    @Test
+    void shouldCanonicalizeProviderTypeAndInstanceBindings() {
+        DataSourceDTO request = validDataSource();
+        request.setType(" victoria metrics ");
+        request.setInstanceIds(List.of(" instance-a ", "instance-b", 
"instance-a"));
+
+        DataSourceVO dataSource = request.toDataSourceVO();
+
+        assertThat(dataSource.getType()).isEqualTo("VictoriaMetrics");
+        assertThat(dataSource.getInstanceIds()).containsExactly("instance-a", 
"instance-b");
+    }
+
+    @Test
+    void shouldRejectBlankInstanceBindings() {
+        DataSourceDTO request = validDataSource();
+        request.setInstanceIds(List.of("instance-a", " "));
+
+        try (ValidatorFactory validatorFactory = 
Validation.buildDefaultValidatorFactory()) {
+            assertThat(validatorFactory.getValidator().validate(request))
+                    .anyMatch(violation -> "instanceIds must not contain blank 
values"
+                            .equals(violation.getMessage()));
+        }
+    }
+
+    @Test
+    void shouldKeepNullInstanceBindingsForGlobalDataSources() {
+        DataSourceDTO request = validDataSource();
+
+        assertThat(request.toDataSourceVO().getInstanceIds()).isNull();
+    }
+
+    private DataSourceDTO validDataSource() {
+        DataSourceDTO request = new DataSourceDTO();
+        request.setName("Production metrics");
+        request.setType("Prometheus");
+        request.setUrl("https://metrics.example.test";);
+        return request;
+    }
+}

Reply via email to