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 301008d62 fix: auth concurrency, history columns, metrics histograms, 
resource name rules, registry probes, LiteTopic quotas (#2516)
301008d62 is described below

commit 301008d62a1c6cb7c6793dffea6bafd5b07ac919
Author: btlqql <[email protected]>
AuthorDate: Sat Aug 22 16:44:01 2026 +0800

    fix: auth concurrency, history columns, metrics histograms, resource name 
rules, registry probes, LiteTopic quotas (#2516)
    
    * fix(auth): serialize concurrent administrator disables
    
    The check-then-update in setUserEnabled let two concurrent disables of 
different administrators both observe an enabled-admin count of 2 and disable 
everyone, leaving the console without an administrator.
    
    Lock the enabled administrator rows (SELECT ... FOR UPDATE) inside a 
transaction before deciding, so concurrent disables serialize: the winner 
disables its target, the loser re-reads a single remaining administrator and 
fails with a 409. The stale-read case where the target was already disabled 
concurrently no longer trips the last-admin guard.
    
    Fixes #2484
    
    * fix(history): align query-history owner columns with username limits
    
    Usernames may be up to 128 characters, but the queried_by columns of 
rmq_instance_message and rmq_instance_trace were VARCHAR(64). Queries made by 
users with 65-128 character names failed the history insert, and MessageService 
swallowed the exception, so the business query succeeded while the history row 
was silently lost.
    
    Widen both columns to VARCHAR(128) and append idempotent MODIFY upgrades so 
pre-existing databases are upgraded when the DDL is re-applied. The 
rmq_operation_audit.operator column stores the same username and has the 
identical gap, so it is widened to VARCHAR(128) in the same way. The service 
layer keeps recording the full username.
    
    Fixes #2491
    
    * fix(metrics): render native histogram samples in Metrics Explorer
    
    The chart only read series.values, so a query that returned native 
histograms with no scalar samples was plotted as the empty state even though 
data existed.
    
    Derive a trend value per histogram - the observed sum in the metric's unit, 
falling back to the observation count when the sum is blank or non-finite - and 
mark such series with a histogram tag in the legend. Scalar samples still take 
precedence when both are present. The tag's tooltip is localized into the zh/en 
copy instead of hardcoded English.
    
    Fixes #2492
    
    * fix(csv): align resource name validation with RocketMQ rules
    
    The CSV importer accepted / and * in topic names while rejecting the % and 
| characters RocketMQ actually supports, forced consumer group names to start 
with a letter, and applied no length limit at all.
    
    Introduce a shared validateResourceName used by both the topic and group 
CSV imports: the RocketMQ character set [%|a-zA-Z0-9_-] with the Topic cap of 
127 and the group cap of 120. The inline create forms reference the same 
pattern and caps so the UI and the importer agree. The unreferenced translation 
key topic.topicNameRule is removed, since the create forms use inline rule 
messages instead.
    
    Fixes #2493
    
    * fix(cluster): bound timed-out NameServer registry probes
    
    listRegistryClusters fanned probes out onto an unbounded cached thread 
pool. CompletableFuture.orTimeout only completed the wrapper future, so a probe 
blocked on an unreachable NameServer kept its thread, and every refresh 
accumulated more blocked threads.
    
    Replace the pool with a bounded, closeable RegistryProbeRunner: a 
fixed-size worker pool with a bounded queue, explicit cancellation (worker 
interrupt) when the probe deadline is reached, and a saturated queue that 
degrades the offending entry to unavailable instead of throwing. The runner is 
closed on context shutdown. The admin clients it drives already carry a 5s RPC 
timeout (MqAdminExtFactory), so released workers do not pile up behind hung 
calls.
    
    Fixes #2472
    
    * fix(topic): normalize unavailable LiteTopic quotas
    
    A quota with a zero or negative maxTopicCount/maxSessionCount represents an 
unavailable feature, not an exhausted one: isQuotaExceeded reported such quotas 
as exceeded (current >= 0 max), and getRemainingQuota only guarded against null 
maxes.
    
    Treat non-positive limits the same as unset limits everywhere: usage rates 
report 0, isQuotaExceeded requires a positive max, and getRemainingQuota 
returns 0. The console then shows "unavailable" instead of "quota exceeded" for 
LiteTopic plans that are not enabled.
---
 .../apache/rocketmq/studio/auth/AuthService.java   |  22 ++--
 .../studio/cluster/broker/ClusterService.java      |  49 ++++----
 .../studio/cluster/broker/RegistryProbeRunner.java | 139 +++++++++++++++++++++
 .../rocketmq/studio/model/LiteTopicQuota.java      |   8 +-
 server/src/main/resources/db/schema.sql            |  15 ++-
 .../AuthServiceConcurrencyIntegrationTest.java     | 124 ++++++++++++++++++
 .../studio/auth/AuthServiceDatabaseTest.java       |  30 ++++-
 .../cluster/broker/ClusterServiceRegistryTest.java |  72 +++++++++++
 .../QueryHistoryServiceIntegrationTest.java        |  73 +++++++++++
 .../rocketmq/studio/model/LiteTopicQuotaTest.java  |  24 +++-
 web/src/components/MetricsExplorer.tsx             |  84 +++++++++++--
 .../components/__tests__/MetricsExplorer.test.tsx  |  76 ++++++++++-
 web/src/i18n/translations.ts                       |   4 -
 web/src/pages/instance/consumer.tsx                |  10 +-
 web/src/pages/instance/topic.tsx                   |  10 +-
 web/src/utils/resourceCsvImport.test.ts            |  42 +++++++
 web/src/utils/resourceCsvImport.ts                 |  38 ++++--
 17 files changed, 745 insertions(+), 75 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
index d5a67f4ba..21ae0d07a 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
@@ -31,6 +31,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
@@ -184,11 +185,22 @@ public class AuthService {
         return user;
     }
 
+    @Transactional
     public RmqStudioUser setUserEnabled(Long userId, boolean enabled) {
         requireDatabaseBacked();
         RmqStudioUser user = getUser(userId);
-        if (!enabled && Boolean.TRUE.equals(user.getAdmin()) && 
enabledAdminCount() <= 1) {
-            throw new BusinessException(409, "The last enabled administrator 
cannot be disabled");
+        if (!enabled && Boolean.TRUE.equals(user.getAdmin())) {
+            // Lock the enabled administrator rows so concurrent disables 
serialize. A plain
+            // count would let two requests both observe a count of 2 and 
disable everyone.
+            List<RmqStudioUser> enabledAdmins = userMapper.selectList(new 
QueryWrapper<RmqStudioUser>()
+                    .eq("admin", true)
+                    .eq("enabled", true)
+                    .last("FOR UPDATE"));
+            boolean targetStillEnabled = enabledAdmins.stream()
+                    .anyMatch(admin -> userId.equals(admin.getId()));
+            if (targetStillEnabled && enabledAdmins.size() <= 1) {
+                throw new BusinessException(409, "The last enabled 
administrator cannot be disabled");
+            }
         }
         userMapper.updateById(userWithEnabled(user, enabled));
         if (!enabled) {
@@ -339,12 +351,6 @@ public class AuthService {
         return update;
     }
 
-    private long enabledAdminCount() {
-        return userMapper.selectCount(new QueryWrapper<RmqStudioUser>()
-                .eq("admin", true)
-                .eq("enabled", true));
-    }
-
     private void revokeUserSessions(Long userId) {
         sessionMapper.update(null, new UpdateWrapper<RmqStudioSession>()
                 .eq("user_id", userId)
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
index a527b24f6..e7566deb0 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/ClusterService.java
@@ -35,6 +35,7 @@ import 
org.apache.rocketmq.studio.common.domain.enums.FlushDiskType;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.ops.audit.AuditService;
 import org.apache.rocketmq.studio.provider.apache.RocketMQBrokerConfigService;
+import jakarta.annotation.PreDestroy;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -42,10 +43,6 @@ import org.springframework.stereotype.Service;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Properties;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
 
 @Slf4j
 @Service
@@ -53,12 +50,8 @@ import java.util.concurrent.TimeUnit;
 public class ClusterService {
 
     private static final long REGISTRY_PROBE_TIMEOUT_SECONDS = 15;
-
-    private static final ExecutorService REGISTRY_PROBE_EXECUTOR = 
Executors.newCachedThreadPool(runnable -> {
-        Thread thread = new Thread(runnable, "nameserver-registry-probe");
-        thread.setDaemon(true);
-        return thread;
-    });
+    private static final int REGISTRY_PROBE_MAX_CONCURRENCY = 8;
+    private static final int REGISTRY_PROBE_QUEUE_CAPACITY = 32;
 
     private final ClusterRepository clusterRepository;
     private final ClusterProvider clusterProvider;
@@ -66,6 +59,20 @@ public class ClusterService {
     private final AuditService auditService;
     private final NameserverRegistryService registryService;
 
+    // Bounded so blocked probes cannot accumulate threads; replaceable in 
unit tests.
+    private RegistryProbeRunner registryProbeRunner = new RegistryProbeRunner(
+            REGISTRY_PROBE_MAX_CONCURRENCY, REGISTRY_PROBE_QUEUE_CAPACITY,
+            REGISTRY_PROBE_TIMEOUT_SECONDS * 1000L);
+
+    void setRegistryProbeRunner(RegistryProbeRunner registryProbeRunner) {
+        this.registryProbeRunner = registryProbeRunner;
+    }
+
+    @PreDestroy
+    void closeRegistryProbeRunner() {
+        registryProbeRunner.close();
+    }
+
     public List<ClusterVO> listClusters() {
         log.info("Listing all clusters");
         List<ClusterVO> discovered = clusterProvider.discoverClusters();
@@ -82,25 +89,13 @@ public class ClusterService {
      * logged and skipped without affecting the other entries.
      */
     public List<ClusterVO> listRegistryClusters() {
-        List<NameserverRegistryVO> entries = registryService.list();
-        if (entries.isEmpty()) {
-            return List.of();
-        }
-        List<CompletableFuture<List<ClusterVO>>> futures = entries.stream()
+        List<NameserverRegistryVO> probeable = registryService.list().stream()
                 .filter(entry -> entry.getNamesrvAddr() != null && 
!entry.getNamesrvAddr().isBlank())
-                .map(entry -> CompletableFuture
-                        .supplyAsync(() -> probeRegistryEntry(entry), 
REGISTRY_PROBE_EXECUTOR)
-                        .orTimeout(REGISTRY_PROBE_TIMEOUT_SECONDS, 
TimeUnit.SECONDS)
-                        .exceptionally(ex -> {
-                            log.warn("NameServer registry probe timed out or 
failed for {} ({}): {}",
-                                    entry.getName(), entry.getNamesrvAddr(), 
ex.getMessage());
-                            return List.of();
-                        }))
-                .toList();
-        return futures.stream()
-                .map(CompletableFuture::join)
-                .flatMap(List::stream)
                 .toList();
+        if (probeable.isEmpty()) {
+            return List.of();
+        }
+        return registryProbeRunner.probeAll(probeable, 
this::probeRegistryEntry);
     }
 
     private List<ClusterVO> probeRegistryEntry(NameserverRegistryVO entry) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RegistryProbeRunner.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RegistryProbeRunner.java
new file mode 100644
index 000000000..16be981a6
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/broker/RegistryProbeRunner.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.cluster.broker;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.cluster.nameserver.NameserverRegistryVO;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Bounded, closeable executor that runs NameServer registry probes 
concurrently.
+ *
+ * <p>Replaces an unbounded cached thread pool so that probes blocked on an 
unreachable
+ * NameServer cannot accumulate threads without limit. Each probe is cancelled 
(its worker
+ * interrupted) when the deadline is reached so the pool thread is released, 
and a saturated
+ * queue degrades the offending entry to unavailable instead of growing the 
pool.</p>
+ */
+@Slf4j
+class RegistryProbeRunner implements AutoCloseable {
+
+    private static final long KEEP_ALIVE_SECONDS = 60L;
+
+    private final ThreadPoolExecutor executor;
+    private final long timeoutMillis;
+    private final int maxConcurrency;
+
+    RegistryProbeRunner(int maxConcurrency, int queueCapacity, long 
timeoutMillis) {
+        this.maxConcurrency = maxConcurrency;
+        this.timeoutMillis = timeoutMillis;
+        this.executor = new ThreadPoolExecutor(
+                maxConcurrency,
+                maxConcurrency,
+                KEEP_ALIVE_SECONDS,
+                TimeUnit.SECONDS,
+                new LinkedBlockingQueue<>(queueCapacity),
+                runnable -> {
+                    Thread thread = new Thread(runnable, 
"nameserver-registry-probe");
+                    thread.setDaemon(true);
+                    return thread;
+                },
+                new ThreadPoolExecutor.AbortPolicy());
+        // Let idle workers (including core) time out so a quiet registry 
holds no threads.
+        this.executor.allowCoreThreadTimeOut(true);
+    }
+
+    /** Probes a single registry entry; may block on the underlying admin 
client. */
+    interface ProbeFunction {
+        List<ClusterVO> probe(NameserverRegistryVO entry) throws Exception;
+    }
+
+    /**
+     * Probes every entry concurrently, bounded by the pool. A rejected 
(saturated), timed-out
+     * or failing entry contributes an empty result without affecting the 
others.
+     */
+    List<ClusterVO> probeAll(List<NameserverRegistryVO> entries, ProbeFunction 
function) {
+        return entries.stream()
+                .map(entry -> probeOne(entry, function))
+                .map(CompletableFuture::join)
+                .flatMap(List::stream)
+                .toList();
+    }
+
+    private CompletableFuture<List<ClusterVO>> probeOne(NameserverRegistryVO 
entry, ProbeFunction function) {
+        CompletableFuture<List<ClusterVO>> result = new CompletableFuture<>();
+        Future<?> task;
+        try {
+            task = executor.submit(() -> {
+                try {
+                    result.complete(function.probe(entry));
+                } catch (Exception exception) {
+                    result.completeExceptionally(exception);
+                }
+            });
+        } catch (RejectedExecutionException rejected) {
+            // Queue saturated: degrade this entry to unavailable instead of 
growing the pool.
+            log.warn("NameServer registry probe rejected for {} ({}): executor 
saturated",
+                    entry.getName(), entry.getNamesrvAddr());
+            return CompletableFuture.completedFuture(List.of());
+        }
+        return result
+                .orTimeout(timeoutMillis, TimeUnit.MILLISECONDS)
+                .whenComplete((value, error) -> {
+                    if (error instanceof TimeoutException) {
+                        // Interrupt the worker so the blocked probe releases 
its pool thread.
+                        task.cancel(true);
+                    }
+                })
+                .exceptionally(error -> {
+                    log.warn("NameServer registry probe failed for {} ({}): 
{}",
+                            entry.getName(), entry.getNamesrvAddr(), 
rootMessage(error));
+                    return List.of();
+                });
+    }
+
+    int activeCount() {
+        return executor.getActiveCount();
+    }
+
+    int poolSize() {
+        return executor.getPoolSize();
+    }
+
+    @Override
+    public void close() {
+        executor.shutdownNow();
+    }
+
+    private static String rootMessage(Throwable error) {
+        Throwable current = error;
+        while (current.getCause() != null) {
+            current = current.getCause();
+        }
+        String message = current.getMessage();
+        return message == null || message.isBlank()
+                ? current.getClass().getSimpleName()
+                : message;
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/model/LiteTopicQuota.java 
b/server/src/main/java/org/apache/rocketmq/studio/model/LiteTopicQuota.java
index 107e07eda..40a1f25a4 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/model/LiteTopicQuota.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/model/LiteTopicQuota.java
@@ -38,14 +38,14 @@ public class LiteTopicQuota {
     private Double maxCreationRate;
 
     public double getUsageRate() {
-        if (maxTopicCount == null || maxTopicCount == 0 || currentTopicCount 
== null) {
+        if (maxTopicCount == null || maxTopicCount <= 0 || currentTopicCount 
== null) {
             return 0.0;
         }
         return (double) currentTopicCount / maxTopicCount;
     }
 
     public double getSessionUsageRate() {
-        if (maxSessionCount == null || maxSessionCount == 0 || 
currentSessionCount == null) {
+        if (maxSessionCount == null || maxSessionCount <= 0 || 
currentSessionCount == null) {
             return 0.0;
         }
         return (double) currentSessionCount / maxSessionCount;
@@ -57,12 +57,12 @@ public class LiteTopicQuota {
 
     public boolean isQuotaExceeded() {
         // Guard against unset fields, mirroring getUsageRate; an unconfigured 
max is not exceeded.
-        return maxTopicCount != null && currentTopicCount != null
+        return maxTopicCount != null && maxTopicCount > 0 && currentTopicCount 
!= null
                 && currentTopicCount >= maxTopicCount;
     }
 
     public Integer getRemainingQuota() {
-        if (maxTopicCount == null) {
+        if (maxTopicCount == null || maxTopicCount <= 0) {
             return 0;
         }
         return Math.max(0, maxTopicCount - (currentTopicCount == null ? 0 : 
currentTopicCount));
diff --git a/server/src/main/resources/db/schema.sql 
b/server/src/main/resources/db/schema.sql
index 8f2a67eed..9e58a7d78 100644
--- a/server/src/main/resources/db/schema.sql
+++ b/server/src/main/resources/db/schema.sql
@@ -145,7 +145,7 @@ CREATE TABLE IF NOT EXISTS rmq_instance_message (
   end_time BIGINT,
   result_count INT DEFAULT 0,
   cluster_id VARCHAR(255),
-  queried_by VARCHAR(64),
+  queried_by VARCHAR(128),
   PRIMARY KEY (`id`),
   INDEX idx_message_query_gmt_create (gmt_create),
   INDEX idx_topic (topic)
@@ -161,7 +161,7 @@ CREATE TABLE IF NOT EXISTS rmq_instance_trace (
   node_count INT DEFAULT 0,
   consumer_count INT DEFAULT 0,
   cluster_id VARCHAR(255),
-  queried_by VARCHAR(64),
+  queried_by VARCHAR(128),
   PRIMARY KEY (`id`),
   INDEX idx_msg_id (msg_id),
   INDEX idx_trace_query_gmt_create (gmt_create)
@@ -179,7 +179,7 @@ CREATE TABLE IF NOT EXISTS rmq_operation_audit (
   detail TEXT COMMENT 'JSON: 操作详情/变更内容',
   result VARCHAR(16) DEFAULT 'SUCCESS' COMMENT 'SUCCESS/FAILED',
   error_message TEXT,
-  operator VARCHAR(64),
+  operator VARCHAR(128),
   PRIMARY KEY (`id`),
   INDEX idx_gmt_create (gmt_create),
   INDEX idx_resource (resource_type, resource_name),
@@ -288,3 +288,12 @@ CREATE TABLE IF NOT EXISTS rmq_cloud_credential (
   PRIMARY KEY (`id`),
   UNIQUE KEY uk_vendor_access_key (vendor, access_key)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Idempotent upgrades for databases created before the corresponding CREATE 
statements
+-- were widened. Safe to re-run: on fresh databases the columns already match, 
and on
+-- existing databases the MODIFY below only grows the column width. Usernames 
may be up
+-- to 128 characters (see AuthService), so columns that store the acting 
username — the
+-- query-history owner columns and the audit operator — must match.
+ALTER TABLE rmq_instance_message MODIFY queried_by VARCHAR(128);
+ALTER TABLE rmq_instance_trace MODIFY queried_by VARCHAR(128);
+ALTER TABLE rmq_operation_audit MODIFY operator VARCHAR(128);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceConcurrencyIntegrationTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceConcurrencyIntegrationTest.java
new file mode 100644
index 000000000..60a70138b
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceConcurrencyIntegrationTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.auth;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.persistence.entity.RmqStudioUser;
+import org.apache.rocketmq.studio.persistence.mapper.RmqStudioUserMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(properties = "studio.auth.login-required=true")
+class AuthServiceConcurrencyIntegrationTest {
+
+    @Autowired
+    private AuthService authService;
+
+    @Autowired
+    private RmqStudioUserMapper userMapper;
+
+    @Autowired
+    private PasswordHasher passwordHasher;
+
+    @Test
+    void concurrentDisablesKeepAtLeastOneEnabledAdministratorTest() throws 
Exception {
+        // The in-memory dev database is shared across test classes in one 
JVM, so park any
+        // pre-existing enabled administrators and restore them afterwards to 
keep the race
+        // deterministic.
+        List<RmqStudioUser> parked = userMapper.selectList(new 
QueryWrapper<RmqStudioUser>()
+                .eq("admin", true)
+                .eq("enabled", true));
+        RmqStudioUser first = adminUser("race-admin-one");
+        RmqStudioUser second = adminUser("race-admin-two");
+        userMapper.insert(first);
+        userMapper.insert(second);
+        for (RmqStudioUser parkedUser : parked) {
+            userMapper.update(null, new UpdateWrapper<RmqStudioUser>()
+                    .eq("id", parkedUser.getId())
+                    .set("enabled", false));
+        }
+        try {
+            int[] outcomes = new int[2];
+            Exception[] threadErrors = new Exception[2];
+            CyclicBarrier barrier = new CyclicBarrier(2);
+            Thread firstThread = disableThread(first.getId(), barrier, 
outcomes, threadErrors, 0);
+            Thread secondThread = disableThread(second.getId(), barrier, 
outcomes, threadErrors, 1);
+            firstThread.start();
+            secondThread.start();
+            firstThread.join(TimeUnit.SECONDS.toMillis(30));
+            secondThread.join(TimeUnit.SECONDS.toMillis(30));
+
+            assertThat(firstThread.isAlive()).isFalse();
+            assertThat(secondThread.isAlive()).isFalse();
+            assertThat(threadErrors[0]).isNull();
+            assertThat(threadErrors[1]).isNull();
+            // Exactly one disable may win the race; the other must fail with 
a conflict.
+            assertThat(outcomes[0] + outcomes[1]).isEqualTo(1);
+            long enabledAdmins = userMapper.selectCount(new 
QueryWrapper<RmqStudioUser>()
+                    .eq("admin", true)
+                    .eq("enabled", true));
+            assertThat(enabledAdmins).isEqualTo(1);
+        } finally {
+            userMapper.deleteById(first.getId());
+            userMapper.deleteById(second.getId());
+            for (RmqStudioUser parkedUser : parked) {
+                userMapper.update(null, new UpdateWrapper<RmqStudioUser>()
+                        .eq("id", parkedUser.getId())
+                        .set("enabled", true));
+            }
+        }
+    }
+
+    private Thread disableThread(Long userId, CyclicBarrier barrier, int[] 
outcomes,
+                                 Exception[] threadErrors, int slot) {
+        return new Thread(() -> {
+            try {
+                barrier.await();
+                try {
+                    authService.setUserEnabled(userId, false);
+                    outcomes[slot] = 1;
+                } catch (BusinessException exception) {
+                    if (exception.getCode() != 409) {
+                        throw exception;
+                    }
+                }
+            } catch (Exception exception) {
+                threadErrors[slot] = exception;
+            }
+        }, "auth-disable-" + userId);
+    }
+
+    private RmqStudioUser adminUser(String username) {
+        RmqStudioUser user = new RmqStudioUser();
+        user.setUsername(username);
+        user.setPasswordHash(passwordHasher.hash("password-1"));
+        user.setAdmin(true);
+        user.setEnabled(true);
+        user.setPasswordChangedAt(LocalDateTime.now());
+        return user;
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
index 9ef733e6a..394a410f9 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
@@ -100,13 +100,41 @@ class AuthServiceDatabaseTest {
     void disablingLastEnabledAdministratorIsRejected() {
         RmqStudioUser user = user(1L, "admin", true, true, "password-1");
         when(userMapper.selectById(1L)).thenReturn(user);
-        when(userMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
+        
when(userMapper.selectList(any(Wrapper.class))).thenReturn(List.of(user));
 
         assertThatThrownBy(() -> authService.setUserEnabled(1L, false))
                 .isInstanceOf(BusinessException.class)
                 .hasMessage("The last enabled administrator cannot be 
disabled");
     }
 
+    @Test
+    void disablingAnAdministratorKeepsTheOtherEnabledAdministratorTest() {
+        RmqStudioUser target = user(1L, "admin-one", true, true, "password-1");
+        RmqStudioUser other = user(2L, "admin-two", true, true, "password-1");
+        when(userMapper.selectById(1L)).thenReturn(target);
+        
when(userMapper.selectList(any(Wrapper.class))).thenReturn(List.of(target, 
other));
+
+        RmqStudioUser result = authService.setUserEnabled(1L, false);
+
+        assertThat(result.getEnabled()).isFalse();
+        verify(userMapper).updateById(any(RmqStudioUser.class));
+    }
+
+    @Test
+    void 
disablingAnAlreadyDisabledAdministratorIsNotRejectedByTheOtherAdminTest() {
+        // A concurrent request disabled the target first, so it no longer 
appears among the
+        // enabled administrators; the stale read must not be mistaken for 
"last admin".
+        RmqStudioUser target = user(1L, "admin-one", true, true, "password-1");
+        RmqStudioUser other = user(2L, "admin-two", true, true, "password-1");
+        when(userMapper.selectById(1L)).thenReturn(target);
+        
when(userMapper.selectList(any(Wrapper.class))).thenReturn(List.of(other));
+
+        RmqStudioUser result = authService.setUserEnabled(1L, false);
+
+        assertThat(result.getEnabled()).isFalse();
+        verify(userMapper).updateById(any(RmqStudioUser.class));
+    }
+
     @Test
     void databaseAuthenticationThrottlesLastSeenWrites() {
         RmqStudioUser user = user(1L, "operator", false, true, "password-1");
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceRegistryTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceRegistryTest.java
index 2dcd3781d..45f60db62 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceRegistryTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/broker/ClusterServiceRegistryTest.java
@@ -28,8 +28,12 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
@@ -94,4 +98,72 @@ class ClusterServiceRegistryTest {
 
         assertThat(clusterService.listRegistryClusters()).isEmpty();
     }
+
+    @Test
+    void listRegistryClustersShouldBoundProbeThreadsWhenProbesBlockTest() 
throws Exception {
+        int maxConcurrency = 4;
+        when(registryService.list()).thenReturn(List.of(
+                
NameserverRegistryVO.builder().id(1L).name("ns-1").namesrvAddr("ns-1:9876").build(),
+                
NameserverRegistryVO.builder().id(2L).name("ns-2").namesrvAddr("ns-2:9876").build(),
+                
NameserverRegistryVO.builder().id(3L).name("ns-3").namesrvAddr("ns-3:9876").build(),
+                
NameserverRegistryVO.builder().id(4L).name("ns-4").namesrvAddr("ns-4:9876").build()));
+
+        CountDownLatch block = new CountDownLatch(1);
+        AtomicInteger probes = new AtomicInteger();
+        
when(clusterProvider.discoverClustersAt(anyString())).thenAnswer(invocation -> {
+            probes.incrementAndGet();
+            try {
+                block.await();
+            } catch (InterruptedException exception) {
+                Thread.currentThread().interrupt();
+            }
+            return List.of();
+        });
+
+        try (RegistryProbeRunner runner = new 
RegistryProbeRunner(maxConcurrency, 8, 150)) {
+            clusterService.setRegistryProbeRunner(runner);
+
+            // Two refresh rounds with every probe blocked: a cached thread 
pool would
+            // accumulate a fresh blocked thread per entry per round.
+            assertThat(clusterService.listRegistryClusters()).isEmpty();
+            assertThat(clusterService.listRegistryClusters()).isEmpty();
+
+            // The pool never grows past its bound and both rounds actually 
ran every probe.
+            assertThat(runner.poolSize()).isLessThanOrEqualTo(maxConcurrency);
+            
assertThat(runner.activeCount()).isLessThanOrEqualTo(maxConcurrency);
+            assertThat(probes).hasValue(8);
+        } finally {
+            block.countDown();
+        }
+    }
+
+    @Test
+    void 
listRegistryClustersShouldDegradeSaturatedProbeQueueWithoutThrowingTest() 
throws Exception {
+        // One worker plus a one-slot queue: the third concurrent probe must 
be rejected
+        // and degraded to unavailable rather than growing the pool or failing 
the request.
+        when(registryService.list()).thenReturn(List.of(
+                
NameserverRegistryVO.builder().id(1L).name("ns-1").namesrvAddr("ns-1:9876").build(),
+                
NameserverRegistryVO.builder().id(2L).name("ns-2").namesrvAddr("ns-2:9876").build(),
+                
NameserverRegistryVO.builder().id(3L).name("ns-3").namesrvAddr("ns-3:9876").build()));
+
+        CountDownLatch block = new CountDownLatch(1);
+        
when(clusterProvider.discoverClustersAt(anyString())).thenAnswer(invocation -> {
+            try {
+                block.await();
+            } catch (InterruptedException exception) {
+                Thread.currentThread().interrupt();
+            }
+            return List.of();
+        });
+
+        try (RegistryProbeRunner runner = new RegistryProbeRunner(1, 1, 150)) {
+            clusterService.setRegistryProbeRunner(runner);
+
+            assertThatCode(() -> 
assertThat(clusterService.listRegistryClusters()).isEmpty())
+                    .doesNotThrowAnyException();
+            assertThat(runner.poolSize()).isLessThanOrEqualTo(1);
+        } finally {
+            block.countDown();
+        }
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceIntegrationTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceIntegrationTest.java
new file mode 100644
index 000000000..31c9d6537
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/message/QueryHistoryServiceIntegrationTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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.instance.message;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.persistence.entity.RmqMessageQuery;
+import org.apache.rocketmq.studio.persistence.entity.RmqTraceQuery;
+import org.apache.rocketmq.studio.persistence.mapper.RmqMessageQueryMapper;
+import org.apache.rocketmq.studio.persistence.mapper.RmqTraceQueryMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(properties = "studio.auth.login-required=true")
+class QueryHistoryServiceIntegrationTest {
+
+    @Autowired
+    private QueryHistoryService queryHistoryService;
+
+    @Autowired
+    private RmqMessageQueryMapper messageQueryMapper;
+
+    @Autowired
+    private RmqTraceQueryMapper traceQueryMapper;
+
+    @Test
+    void queryHistoryKeepsFullLengthUsernamesTest() {
+        // Usernames may be up to 128 characters; the query-history owner 
columns must
+        // store the full value instead of rejecting or truncating it.
+        String longUsername = "long".repeat(32);
+        assertThat(longUsername).hasSize(128);
+        try {
+            AuthenticatedUserContext.setUser(longUsername, true);
+            queryHistoryService.recordMessageQuery("qh-cluster", "TOPIC", 
"qh-topic",
+                    null, null, null, null, null, 3);
+            queryHistoryService.recordTraceQuery("qh-cluster", "qh-msg-id", 
"qh-topic", 2, 1);
+
+            PageResult<MessageQueryHistoryVO> messageHistory =
+                    queryHistoryService.listMessageQueries("qh-cluster", null, 
null, 1, 20);
+            assertThat(messageHistory.getItems()).anySatisfy(item ->
+                    assertThat(item.getQueriedBy()).isEqualTo(longUsername));
+
+            PageResult<TraceQueryHistoryVO> traceHistory =
+                    queryHistoryService.listTraceQueries("qh-cluster", null, 
1, 20);
+            assertThat(traceHistory.getItems()).anySatisfy(item ->
+                    assertThat(item.getQueriedBy()).isEqualTo(longUsername));
+        } finally {
+            AuthenticatedUserContext.clear();
+            messageQueryMapper.delete(new QueryWrapper<RmqMessageQuery>()
+                    .eq("queried_by", longUsername));
+            traceQueryMapper.delete(new QueryWrapper<RmqTraceQuery>()
+                    .eq("queried_by", longUsername));
+        }
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/model/LiteTopicQuotaTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/model/LiteTopicQuotaTest.java
index 3825f91d4..b585fdc6b 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/model/LiteTopicQuotaTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/model/LiteTopicQuotaTest.java
@@ -25,12 +25,34 @@ class LiteTopicQuotaTest {
     }
 
     @Test
-    void quotaShouldBeExceededWhenCurrentReachesMax() {
+    void nonPositiveLimitsShouldRemainUnavailableInsteadOfExceeded() {
+        LiteTopicQuota quota = new LiteTopicQuota();
+        quota.setMaxTopicCount(0);
+        quota.setCurrentTopicCount(1);
+        quota.setMaxSessionCount(-1);
+        quota.setCurrentSessionCount(1);
+
+        assertThat(quota.getUsageRate()).isZero();
+        assertThat(quota.getSessionUsageRate()).isZero();
+        assertThat(quota.isQuotaExceeded()).isFalse();
+        assertThat(quota.getRemainingQuota()).isZero();
+    }
+
+    @Test
+    void quotaShouldBeExceededOnlyAtOrAbovePositiveMax() {
         LiteTopicQuota quota = new LiteTopicQuota();
         quota.setMaxTopicCount(10);
+
+        quota.setCurrentTopicCount(9);
+        assertThat(quota.isQuotaExceeded()).isFalse();
+        assertThat(quota.getRemainingQuota()).isEqualTo(1);
+
         quota.setCurrentTopicCount(10);
+        assertThat(quota.isQuotaExceeded()).isTrue();
 
+        quota.setCurrentTopicCount(11);
         assertThat(quota.isQuotaExceeded()).isTrue();
+        assertThat(quota.getRemainingQuota()).isZero();
     }
 
     @Test
diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
index 0c38fdfd1..d4df753ab 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -57,13 +57,49 @@ interface NumericSample {
   value: number;
 }
 
-const toNumericSamples = (series: MetricSeries): NumericSample[] =>
-  series.values
-    .map((sample, index) => ({ timestamp: sample.timestamp, value: 
Number(sample.value), index }))
+const sortAndStrip = (
+  samples: { timestamp: number; value: number; index: number }[],
+): NumericSample[] =>
+  samples
     .filter((sample) => Number.isFinite(sample.timestamp) && 
Number.isFinite(sample.value))
     .sort((left, right) => left.timestamp - right.timestamp || left.index - 
right.index)
     .map(({ timestamp, value }) => ({ timestamp, value }));
 
+const toScalarSamples = (series: MetricSeries): NumericSample[] =>
+  sortAndStrip(
+    series.values.map((sample, index) => ({
+      timestamp: sample.timestamp,
+      value: Number(sample.value),
+      index,
+    })),
+  );
+
+// Native histograms carry no scalar samples. To avoid plotting them as "no 
data", derive a
+// trend value per histogram: the observed sum (in the metric's unit), falling 
back to the
+// observation count when the sum is absent or non-finite.
+const toHistogramSamples = (series: MetricSeries): NumericSample[] =>
+  sortAndStrip(
+    series.histograms.map((sample, index) => {
+      // An empty string parses to 0, so treat a blank field as missing rather 
than zero.
+      const sumText = sample.histogram.sum?.trim();
+      const countText = sample.histogram.count?.trim();
+      const sum = sumText ? Number(sumText) : Number.NaN;
+      const count = countText ? Number(countText) : Number.NaN;
+      const value = Number.isFinite(sum) ? sum : count;
+      return { timestamp: sample.timestamp, value, index };
+    }),
+  );
+
+const toNumericSamples = (
+  series: MetricSeries,
+): { samples: NumericSample[]; fromHistogram: boolean } => {
+  const scalar = toScalarSamples(series);
+  if (scalar.length > 0) {
+    return { samples: scalar, fromHistogram: false };
+  }
+  return { samples: toHistogramSamples(series), fromHistogram: true };
+};
+
 const seriesLabel = (series: MetricSeries, fallback: string) => {
   const labels = Object.entries(series.labels)
     .filter(([key]) => key !== '__name__')
@@ -80,15 +116,28 @@ interface MetricChartProps {
   metric: MetricMapping;
   locale: string;
   noSamples: string;
+  histogramLabel: string;
+  histogramTooltip: string;
 }
 
-const MetricChart = ({ data, metric, locale, noSamples }: MetricChartProps) => 
{
+const MetricChart = ({
+  data,
+  metric,
+  locale,
+  noSamples,
+  histogramLabel,
+  histogramTooltip,
+}: MetricChartProps) => {
   const chartSeries = data.series
-    .map((series, index) => ({
-      color: SERIES_COLORS[index % SERIES_COLORS.length],
-      label: seriesLabel(series, metric.name),
-      samples: toNumericSamples(series),
-    }))
+    .map((series, index) => {
+      const { samples, fromHistogram } = toNumericSamples(series);
+      return {
+        color: SERIES_COLORS[index % SERIES_COLORS.length],
+        label: seriesLabel(series, metric.name),
+        samples,
+        fromHistogram,
+      };
+    })
     .filter((series) => series.samples.length > 0);
 
   if (chartSeries.length === 0) {
@@ -205,6 +254,13 @@ const MetricChart = ({ data, metric, locale, noSamples }: 
MetricChartProps) => {
               <Text ellipsis={{ tooltip: series.label }} style={{ maxWidth: 
220 }}>
                 {series.label}
               </Text>
+              {series.fromHistogram ? (
+                <Tooltip title={histogramTooltip}>
+                  <Tag color="purple" style={{ marginInlineEnd: 0 }}>
+                    {histogramLabel}
+                  </Tag>
+                </Tooltip>
+              ) : null}
               <Text strong>
                 {formatMetricValue(latest.value)} {metric.unit}
               </Text>
@@ -272,7 +328,9 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
           profileError: '指标模板加载失败',
           queryError: queryErrorFallback,
           noProfiles: '暂无指标模板',
-          noSamples: '暂无标量数据',
+          noSamples: '暂无数据',
+          histogram: '直方图',
+          histogramTooltip: '无标量样本,趋势由直方图观测值推导',
           defaultDataSource: '默认数据源',
           authTitle: '数据源认证',
           authDescription: '凭据仅用于当前数据源,离开该数据源后会被清除。',
@@ -292,7 +350,9 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
           profileError: 'Failed to load metric profiles',
           queryError: queryErrorFallback,
           noProfiles: 'No metric profiles',
-          noSamples: 'No scalar samples',
+          noSamples: 'No samples',
+          histogram: 'Histogram',
+          histogramTooltip: 'No scalar samples; trend derived from histogram 
observations',
           defaultDataSource: 'Default source',
           authTitle: 'Data source authentication',
           authDescription:
@@ -607,6 +667,8 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
             metric={selectedMetric}
             locale={lang === 'zh' ? 'zh-CN' : 'en-US'}
             noSamples={copy.noSamples}
+            histogramLabel={copy.histogram}
+            histogramTooltip={copy.histogramTooltip}
           />
         </>
       ) : null}
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx 
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index aee5234fc..b14a3f140 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -84,6 +84,27 @@ const metricData = {
   warnings: [],
 };
 
+const histogramOnlyData = {
+  resultType: 'matrix',
+  series: [
+    {
+      labels: { cluster: 'prod', node_id: 'broker-a' },
+      values: [],
+      histograms: [
+        {
+          timestamp: 1_799_996_400,
+          histogram: { count: '10', sum: '250', buckets: [] },
+        },
+        {
+          timestamp: 1_800_000_000,
+          histogram: { count: '20', sum: '600', buckets: [] },
+        },
+      ],
+    },
+  ],
+  warnings: [],
+};
+
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
     writable: true,
@@ -257,12 +278,63 @@ describe('MetricsExplorer', () => {
     expect(await screen.findByText('Prometheus base URL is not 
configured')).toBeInTheDocument();
   });
 
-  it('shows an empty state when Prometheus returns no scalar samples', async 
() => {
+  it('shows an empty state when Prometheus returns no samples at all', async 
() => {
     vi.mocked(queryMetrics).mockResolvedValue({ ...metricData, series: [] });
 
     renderWithProviders(<MetricsExplorer />);
 
-    expect(await screen.findByText('暂无标量数据')).toBeInTheDocument();
+    expect(await screen.findByText('暂无数据')).toBeInTheDocument();
+  });
+
+  it('renders histogram-only series from observed sums instead of an empty 
state', async () => {
+    vi.mocked(queryMetrics).mockResolvedValue(histogramOnlyData);
+
+    renderWithProviders(<MetricsExplorer />);
+
+    expect(
+      await screen.findByRole('img', { name: 'Message In TPS time series' }),
+    ).toBeInTheDocument();
+    expect(screen.getByText('600 messages/s')).toBeInTheDocument();
+    expect(screen.getByText('直方图')).toBeInTheDocument();
+    expect(screen.queryByText('暂无数据')).not.toBeInTheDocument();
+  });
+
+  it('falls back to the observation count when the histogram sum is missing', 
async () => {
+    vi.mocked(queryMetrics).mockResolvedValue({
+      ...histogramOnlyData,
+      series: [
+        {
+          ...histogramOnlyData.series[0],
+          histograms: [
+            { timestamp: 1_800_000_000, histogram: { count: '20', sum: '', 
buckets: [] } },
+          ],
+        },
+      ],
+    });
+
+    renderWithProviders(<MetricsExplorer />);
+
+    expect(await screen.findByText('20 messages/s')).toBeInTheDocument();
+  });
+
+  it('prefers scalar samples when a series has both values and histograms', 
async () => {
+    vi.mocked(queryMetrics).mockResolvedValue({
+      ...metricData,
+      series: [
+        {
+          ...metricData.series[0],
+          histograms: [
+            { timestamp: 1_800_000_000, histogram: { count: '99', sum: '999', 
buckets: [] } },
+          ],
+        },
+      ],
+    });
+
+    renderWithProviders(<MetricsExplorer />);
+
+    expect(await screen.findByText('42 messages/s')).toBeInTheDocument();
+    expect(screen.queryByText('999 messages/s')).not.toBeInTheDocument();
+    expect(screen.queryByText('直方图')).not.toBeInTheDocument();
   });
 
   it('queries the selected data source through the datasource endpoint', async 
() => {
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 648e2ae88..171c08a28 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -753,10 +753,6 @@ const translations: Record<string, Record<Lang, string>> = 
{
     en: 'Topic "{name}" created successfully',
   },
   'topic.topicNamePlaceholder': { zh: '请输入 Topic 名称', en: 'Enter topic name' },
-  'topic.topicNameRule': {
-    zh: '仅支持字母、数字、下划线、中划线、斜杠和星号',
-    en: 'Only letters, numbers, underscore, hyphen, slash and asterisk',
-  },
   'topic.topicNameRequired': { zh: '请输入 Topic 名称', en: 'Please enter topic 
name' },
   'topic.queueExtra': { zh: '每个 Broker 节点 8 个队列', en: '8 queues per Broker 
node' },
   'topic.remarkPlaceholder': {
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index cff253685..f8bc9d8ca 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -95,6 +95,8 @@ import {
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
 import {
   parseCsvTable,
+  RESOURCE_NAME_MAX_LENGTH,
+  RESOURCE_NAME_PATTERN,
   validateConsumerGroupCsvImport,
   type ResourceImportRow,
 } from '../../utils/resourceCsvImport';
@@ -1743,8 +1745,12 @@ const ConsumerPageContent = ({
             rules={[
               { required: true, message: '请输入 Group 名称' },
               {
-                pattern: /^[a-zA-Z][a-zA-Z0-9_-]*$/,
-                message: '名称以字母开头,仅包含字母、数字、下划线和短横线',
+                pattern: RESOURCE_NAME_PATTERN,
+                message: '仅支持字母、数字、下划线、短横线、% 和 |',
+              },
+              {
+                max: RESOURCE_NAME_MAX_LENGTH.group,
+                message: `名称不能超过 ${RESOURCE_NAME_MAX_LENGTH.group} 个字符`,
               },
             ]}
           >
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index c2bc59e59..da01ce078 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -74,6 +74,8 @@ import { useInstanceFilter } from 
'../../hooks/useInstanceFilter';
 import type { Instance } from '../../api/instance';
 import {
   parseCsvTable,
+  RESOURCE_NAME_MAX_LENGTH,
+  RESOURCE_NAME_PATTERN,
   validateTopicCsvImport,
   type ResourceImportRow,
 } from '../../utils/resourceCsvImport';
@@ -1329,8 +1331,12 @@ const TopicPage = () => {
             rules={[
               { required: true, message: '请输入 Topic 名称' },
               {
-                pattern: /^[a-zA-Z0-9_\-/*]+$/,
-                message: '仅支持字母、数字、下划线、中划线、斜杠和星号',
+                pattern: RESOURCE_NAME_PATTERN,
+                message: '仅支持字母、数字、下划线、短横线、% 和 |',
+              },
+              {
+                max: RESOURCE_NAME_MAX_LENGTH.topic,
+                message: `名称不能超过 ${RESOURCE_NAME_MAX_LENGTH.topic} 个字符`,
               },
             ]}
           >
diff --git a/web/src/utils/resourceCsvImport.test.ts 
b/web/src/utils/resourceCsvImport.test.ts
index 4621e38df..979f38fb9 100644
--- a/web/src/utils/resourceCsvImport.test.ts
+++ b/web/src/utils/resourceCsvImport.test.ts
@@ -20,6 +20,7 @@ import {
   parseCsvTable,
   RESOURCE_IMPORT_ROW_LIMIT,
   validateConsumerGroupCsvImport,
+  validateResourceName,
   validateTopicCsvImport,
 } from './resourceCsvImport';
 
@@ -125,4 +126,45 @@ describe('resourceCsvImport', () => {
       instanceId: 'instance-2',
     });
   });
+
+  it('aligns topic and group names with the RocketMQ validators', () => {
+    const topicStatus = (name: string) =>
+      validateTopicCsvImport(parseCsvTable(['"Name"', 
`"${name}"`].join('\n'))).rows[0].status;
+    const groupStatus = (name: string) =>
+      validateConsumerGroupCsvImport(parseCsvTable(['"Name"', 
`"${name}"`].join('\n'))).rows[0]
+        .status;
+
+    // RocketMQ accepts % and | in both topic and group names
+    expect(topicStatus('100%topic')).toBe('pending');
+    expect(topicStatus('topic|pipe')).toBe('pending');
+    expect(groupStatus('cg|pipe')).toBe('pending');
+    // Groups may start with a digit (no leading-letter rule)
+    expect(groupStatus('1cg')).toBe('pending');
+    // / and * are not part of the RocketMQ name character set
+    expect(topicStatus('topic/with-slash')).toBe('invalid');
+    expect(topicStatus('topic*star')).toBe('invalid');
+  });
+
+  it('applies the RocketMQ length caps to imported names', () => {
+    const topicStatus = (name: string) =>
+      validateTopicCsvImport(parseCsvTable(['"Name"', 
`"${name}"`].join('\n'))).rows[0].status;
+    const groupStatus = (name: string) =>
+      validateConsumerGroupCsvImport(parseCsvTable(['"Name"', 
`"${name}"`].join('\n'))).rows[0]
+        .status;
+
+    expect(topicStatus('t'.repeat(127))).toBe('pending');
+    expect(topicStatus('t'.repeat(128))).toBe('invalid');
+    expect(groupStatus('g'.repeat(120))).toBe('pending');
+    expect(groupStatus('g'.repeat(121))).toBe('invalid');
+  });
+
+  it('reports the RocketMQ-oriented name error messages', () => {
+    expect(validateResourceName('', 'topic')).toBe('Name 不能为空');
+    expect(validateResourceName('a'.repeat(128), 'topic')).toBe('Name 长度不能超过 
127 个字符');
+    expect(validateResourceName('a'.repeat(121), 'group')).toBe('Name 长度不能超过 
120 个字符');
+    expect(validateResourceName('bad/name', 'topic')).toBe(
+      'Name 仅支持字母、数字、下划线、短横线、% 和 |',
+    );
+    expect(validateResourceName('ok-name|100%', 'group')).toBeNull();
+  });
 });
diff --git a/web/src/utils/resourceCsvImport.ts 
b/web/src/utils/resourceCsvImport.ts
index 19a6d10f7..bda78d46a 100644
--- a/web/src/utils/resourceCsvImport.ts
+++ b/web/src/utils/resourceCsvImport.ts
@@ -44,8 +44,28 @@ interface ParsedCsvRow {
 }
 
 const FORMULA_SAFE_PREFIX_PATTERN = /^'(?=[=+\-@\t\r\n])/;
-const TOPIC_NAME_PATTERN = /^[a-zA-Z0-9_\-/*]+$/;
-const GROUP_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
+
+// Aligned with RocketMQ's TopicValidator/GroupValidator: a shared character 
set (letters,
+// digits, underscore, hyphen, % and |) with per-kind length caps. Topics cap 
at 127 and
+// consumer groups at 120; both may start with a digit or symbol, so no 
leading-letter rule.
+export const RESOURCE_NAME_PATTERN = /^[%|a-zA-Z0-9_-]+$/;
+export const RESOURCE_NAME_MAX_LENGTH = { topic: 127, group: 120 } as const;
+
+export type ResourceNameKind = keyof typeof RESOURCE_NAME_MAX_LENGTH;
+
+export const validateResourceName = (name: string, kind: ResourceNameKind): 
string | null => {
+  if (!name) {
+    return 'Name 不能为空';
+  }
+  const maxLength = RESOURCE_NAME_MAX_LENGTH[kind];
+  if (name.length > maxLength) {
+    return `Name 长度不能超过 ${maxLength} 个字符`;
+  }
+  if (!RESOURCE_NAME_PATTERN.test(name)) {
+    return 'Name 仅支持字母、数字、下划线、短横线、% 和 |';
+  }
+  return null;
+};
 
 const TOPIC_TYPES = new Set(['NORMAL', 'FIFO', 'DELAY', 'TRANSACTION', 
'LITE']);
 const TOPIC_PERMISSIONS = new Set(['RW', 'RO', 'WO']);
@@ -260,10 +280,9 @@ export const validateTopicCsvImport = (
     const duplicateMessage = duplicateMessages.get(record.lineNumber);
     if (duplicateMessage) rowErrors.push(duplicateMessage);
 
-    if (!name) {
-      rowErrors.push('Name 不能为空');
-    } else if (!TOPIC_NAME_PATTERN.test(name)) {
-      rowErrors.push('Name 仅支持字母、数字、下划线、中划线、斜杠和星号');
+    const nameError = validateResourceName(name, 'topic');
+    if (nameError) {
+      rowErrors.push(nameError);
     }
     if (!TOPIC_TYPES.has(type)) {
       rowErrors.push(`Type 不支持:${type}`);
@@ -319,10 +338,9 @@ export const validateConsumerGroupCsvImport = (
     const duplicateMessage = duplicateMessages.get(record.lineNumber);
     if (duplicateMessage) rowErrors.push(duplicateMessage);
 
-    if (!name) {
-      rowErrors.push('Name 不能为空');
-    } else if (!GROUP_NAME_PATTERN.test(name)) {
-      rowErrors.push('Name 需以字母开头,仅包含字母、数字、下划线和短横线');
+    const nameError = validateResourceName(name, 'group');
+    if (nameError) {
+      rowErrors.push(nameError);
     }
     if (!GROUP_SUBSCRIPTION_MODES.has(subscriptionMode)) {
       rowErrors.push(`Subscription Mode 不支持:${subscriptionMode}`);

Reply via email to